AXLR / Catalog / writing

latex_table

Turn CSV text into a LaTeX tabular: the ten special characters (# $ % & { } _ ~ ^ \) escaped as the LaTeX reference manual prescribes, numeric columns right-aligned automatically or alignment given, \hline or booktabs rules, optional siunitx S columns, caption and label in a table float. Returns the LaTeX and the packages it needs.

Install

/tools install latex_table
/tools install latex_table@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · latex_table 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool latex_table; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
csvstringyes
delimiterstringno","length ≥ 1; length ≤ 1
headerbooleannotrue
alignstringnoOne letter per column; default automatic. pattern ^[lcrS]+$
booktabsbooleannofalse
siunitxbooleannofalse
captionstringno
labelstringnopattern ^[A-Za-z0-9:._-]+$
positionstringno"htbp"
escapebooleannotrue
Input schema (JSON)
{
  "type": "object",
  "required": [
    "csv"
  ],
  "additionalProperties": false,
  "properties": {
    "csv": {
      "type": "string"
    },
    "delimiter": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1,
      "default": ","
    },
    "header": {
      "type": "boolean",
      "default": true
    },
    "align": {
      "type": "string",
      "pattern": "^[lcrS]+$",
      "description": "One letter per column; default automatic"
    },
    "booktabs": {
      "type": "boolean",
      "default": false
    },
    "siunitx": {
      "type": "boolean",
      "default": false
    },
    "caption": {
      "type": "string"
    },
    "label": {
      "type": "string",
      "pattern": "^[A-Za-z0-9:._-]+$"
    },
    "position": {
      "type": "string",
      "default": "htbp"
    },
    "escape": {
      "type": "boolean",
      "default": true
    }
  }
}

Example

Input

{
  "csv": "Item,Qty,Price\nApples,3,1.20\nPears & plums,12,0.5\n",
  "booktabs": true
}

Output (stdout)

{
  "alignment": "lrr",
  "numeric_columns": [
    2,
    3
  ],
  "packages": [
    "booktabs"
  ]
}

Files

The command the install card registers is python3 latex_table.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

latex_table.py · sha256 e9c47961db8662a1abb25fd3542fe452c24e1aa4240a009577155ed4551a5539
#!/usr/bin/env python3
"""CSV text to a LaTeX tabular: the ten special characters escaped as the LaTeX reference
manual prescribes, column alignment (numeric columns right-aligned automatically), plain
\\hline rules or booktabs, optional siunitx S columns, caption and label in a table float.

Input (JSON on stdin):
  csv        the CSV text (required)
  delimiter  one character (default ",")
  header     first row is a header (default true)
  align      "lrc..." one letter per column (l, c, r, or S with siunitx); default automatic
  booktabs   \\toprule / \\midrule / \\bottomrule instead of \\hline (default false)
  siunitx    numeric columns as S columns (default false)
  caption, label   put the tabular in a table float with them
  position   float placement (default "htbp")
  escape     escape special characters in cells (default true)
"""
import csv
import io
import json
import re
import sys

SPECIAL = {"#": r"\#", "$": r"\$", "%": r"\%", "&": r"\&", "{": r"\{", "}": r"\}", "_": r"\_",
           "~": r"\~{}", "^": r"\^{}", "\\": r"\textbackslash{}"}
NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def escape(text):
    return "".join(SPECIAL.get(c, c) for c in text)


def get_bool(request, field, default):
    value = request.get(field, default)
    if not isinstance(value, bool):
        fail(f"{field} must be true or false")
    return value


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    text = request.get("csv")
    if not isinstance(text, str) or not text.strip():
        fail("csv must be a non-empty string")
    if len(text) > 2000000:
        fail("csv is longer than 2000000 characters")
    delimiter = request.get("delimiter", ",")
    if not isinstance(delimiter, str) or len(delimiter) != 1 or delimiter in "\"\r\n":
        fail("delimiter must be one character other than a quote or a newline")
    header = get_bool(request, "header", True)
    booktabs = get_bool(request, "booktabs", False)
    siunitx = get_bool(request, "siunitx", False)
    do_escape = get_bool(request, "escape", True)
    caption, label = request.get("caption"), request.get("label")
    if caption is not None and not isinstance(caption, str):
        fail("caption must be a string")
    if label is not None and (not isinstance(label, str) or not re.fullmatch(r"[A-Za-z0-9:._\-]+", label)):
        fail("label must be letters, digits and : . _ - only")
    position = request.get("position", "htbp")
    if not isinstance(position, str) or not re.fullmatch(r"[htbpH!]{1,5}", position):
        fail("position must be made of h t b p H !")
    try:
        rows = [row for row in csv.reader(io.StringIO(text), delimiter=delimiter) if row]
    except csv.Error as error:
        fail(f"csv: {error}")
    if not rows:
        fail("csv has no rows")
    width = max(len(r) for r in rows)
    ragged = sum(len(r) != width for r in rows)
    rows = [r + [""] * (width - len(r)) for r in rows]
    head, body = (rows[0], rows[1:]) if header else (None, rows)
    numeric = []
    for k in range(width):
        cells = [r[k].strip() for r in body if r[k].strip()]
        numeric.append(bool(cells) and all(NUMBER.fullmatch(c) for c in cells))
    align = request.get("align")
    if align is None:
        spec = ["S" if n and siunitx else ("r" if n else "l") for n in numeric]
    else:
        if not isinstance(align, str) or len(align) != width or not re.fullmatch(r"[lcrS]+", align):
            fail(f"align must have one of l, c, r, S for each of the {width} columns")
        if "S" in align and not siunitx:
            fail("S columns need siunitx: true")
        spec = list(align)

    def cell(value, k, is_header):
        value = value.strip()
        out = escape(value) if do_escape else value
        if spec[k] == "S" and (is_header or not NUMBER.fullmatch(value)):
            out = "{" + out + "}" if out else out  # siunitx: protect non-numbers
        elif spec[k] == "S":
            out = value
        return out

    def line(row, is_header=False):
        return "    " + " & ".join(cell(v, k, is_header) for k, v in enumerate(row)) + r" \\"

    top, mid, bottom = (r"\toprule", r"\midrule", r"\bottomrule") if booktabs else (r"\hline", r"\hline", r"\hline")
    lines = [r"\begin{tabular}{" + "".join(spec) + "}", "    " + top]
    if head is not None:
        lines += [line(head, True), "    " + mid]
    lines += [line(r) for r in body]
    lines += ["    " + bottom, r"\end{tabular}"]
    float_env = caption is not None or label is not None
    if float_env:
        inner = ["  " + x for x in lines]
        out = [r"\begin{table}[" + position + "]", r"  \centering"]
        if caption is not None:
            out.append(r"  \caption{" + escape(caption) + "}")
        if label is not None:
            out.append(r"  \label{" + label + "}")
        lines = out + inner + [r"\end{table}"]
    packages = (["booktabs"] if booktabs else []) + (["siunitx"] if "S" in spec else [])
    result = {"latex": "\n".join(lines) + "\n", "columns": width, "rows": len(body), "header": header,
              "alignment": "".join(spec), "numeric_columns": [k + 1 for k, n in enumerate(numeric) if n],
              "packages": packages, "float": float_env}
    if ragged:
        result["warning"] = f"{ragged} rows had fewer cells than {width} and were padded with empty cells"
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/latex_table/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/latex_table/1.0.0/latex_table.py