ltem-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ltem/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """LTEM (LLM-friendly Table-Enhanced Markdown).
2
+
3
+ Reference Python implementation of the LTEM specification, version 1.0:
4
+ Markdown pipe tables with merged cells and multi-row headers.
5
+
6
+ >>> import ltem
7
+ >>> result = ltem.parse_table('''
8
+ ... | Region | Revenue | << |
9
+ ... | ^^ | Q1 | Q2 |
10
+ ... |--------|---------|-----|
11
+ ... | Asia | 100 | 120 |
12
+ ... '''.strip())
13
+ >>> [(c.text, c.rowspan, c.colspan) for c in result.table.cells][:2]
14
+ [('Region', 2, 1), ('Revenue', 1, 2)]
15
+ """
16
+
17
+ from ._syntax import (ends_table_body, indentation, is_delimiter_row, is_upper_header_row, split_row,
18
+ split_row_spans, starts_block)
19
+ from .fromhtml import HtmlTable, from_html, html_to_ltem
20
+ from .html import plain_inline, render_html
21
+ from .model import Cell, CellSource, Problem, Table
22
+ from .parser import ParseResult, find_tables, parse_table
23
+ from .writer import write, write_with_sources
24
+
25
+ __version__ = "0.1.0"
26
+ SPEC_VERSION = "1.0"
27
+
28
+ __all__ = [
29
+ "Cell",
30
+ "CellSource",
31
+ "HtmlTable",
32
+ "ParseResult",
33
+ "Problem",
34
+ "SPEC_VERSION",
35
+ "Table",
36
+ "find_tables",
37
+ "from_html",
38
+ "ends_table_body",
39
+ "html_to_ltem",
40
+ "indentation",
41
+ "is_delimiter_row",
42
+ "is_upper_header_row",
43
+ "parse_table",
44
+ "plain_inline",
45
+ "render_html",
46
+ "split_row",
47
+ "split_row_spans",
48
+ "starts_block",
49
+ "write",
50
+ "write_with_sources",
51
+ ]
ltem/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
ltem/_grid.py ADDED
@@ -0,0 +1,151 @@
1
+ """Span resolution (spec Section 3): grid rows, compact rows, markers and recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from dataclasses import dataclass
7
+
8
+ from ._syntax import COLSPAN_MARKER, ROWSPAN_MARKER, split_attribute
9
+ from .model import Problem
10
+
11
+
12
+ @dataclass
13
+ class _Origin:
14
+ row: int
15
+ col: int
16
+ rowspan: int
17
+ colspan: int
18
+ text: str
19
+ fixed_cols: bool # colspan came from an attribute: markers may not extend it
20
+ fixed_rows: bool # rowspan came from an attribute
21
+ source: int | None = None # index of the cell in its source row; None if added by padding
22
+
23
+
24
+ class GridBuilder:
25
+ """Places the cells of a table row by row, as spec Section 3 describes."""
26
+
27
+ def __init__(self, cols: int, header_rows: int, rows: int) -> None:
28
+ self.cols, self.header_rows, self.rows = cols, header_rows, rows
29
+ self.owner: list[list[_Origin | None]] = [[None] * cols for _ in range(rows)]
30
+ self.origins: list[_Origin] = []
31
+ self.problems: list[Problem] = []
32
+
33
+ # -- helpers ---------------------------------------------------------------
34
+
35
+ def _report(self, rule: str, row: int, col: int | None, message: str) -> None:
36
+ self.problems.append(Problem(rule, row, col, message))
37
+
38
+ def _free(self, r0: int, r1: int, c0: int, c1: int) -> bool:
39
+ return all(self.owner[r][c] is None
40
+ for r in range(r0, min(r1, self.rows)) for c in range(c0, min(c1, self.cols)))
41
+
42
+ def _cover(self, origin: _Origin, r0: int, r1: int, c0: int, c1: int) -> None:
43
+ for r in range(r0, min(r1, self.rows)):
44
+ for c in range(c0, min(c1, self.cols)):
45
+ self.owner[r][c] = origin
46
+
47
+ # -- placing (spec 3.3, 3.4) -------------------------------------------------
48
+
49
+ def _extend_left(self, r: int, c: int) -> bool:
50
+ left = self.owner[r][c - 1] if c > 0 else None
51
+ if left is None or left.row != r or left.col + left.colspan != c or left.fixed_cols:
52
+ self._report("V13", r, c, "'<<' has no cell to extend")
53
+ return False
54
+ if not self._free(r + 1, left.row + left.rowspan, c, c + 1):
55
+ self._report("V4", r, c, "'<<' would make two cells overlap")
56
+ return False
57
+ left.colspan += 1
58
+ self._cover(left, left.row, left.row + left.rowspan, c, c + 1)
59
+ return True
60
+
61
+ def _extend_up(self, r: int, c: int) -> bool:
62
+ up = self.owner[r - 1][c] if r > 0 else None
63
+ if up is None or up.col != c or up.row + up.rowspan != r or up.fixed_rows:
64
+ self._report("V13", r, c, "'^^' has no cell to extend")
65
+ return False
66
+ if up.row < self.header_rows <= r:
67
+ self._report("V7", r, c, "'^^' would extend a header cell into the body")
68
+ return False
69
+ if not self._free(r, r + 1, c + 1, up.col + up.colspan):
70
+ self._report("V4", r, c, "'^^' would make two cells overlap")
71
+ return False
72
+ up.rowspan += 1
73
+ self._cover(up, r, r + 1, up.col, up.col + up.colspan)
74
+ return True
75
+
76
+ def _place(self, r: int, c: int, text: str, source: int | None = None) -> None:
77
+ """Place a cell at the position (r, c), which is not covered."""
78
+ if text == COLSPAN_MARKER and self._extend_left(r, c):
79
+ return
80
+ if text == ROWSPAN_MARKER and self._extend_up(r, c):
81
+ return
82
+ colspan = rowspan = 1
83
+ fixed_cols = fixed_rows = False
84
+ if text not in (COLSPAN_MARKER, ROWSPAN_MARKER): # an unusable marker stays literal text
85
+ text, attr = split_attribute(text)
86
+ if attr:
87
+ for message in attr.problems:
88
+ self._report("V1", r, c, message)
89
+ colspan, fixed_cols = attr.colspan or 1, attr.colspan is not None
90
+ rowspan, fixed_rows = attr.rowspan or 1, attr.rowspan is not None
91
+ if c + colspan > self.cols:
92
+ self._report("V2", r, c, f"colspan {colspan} reaches past the last column")
93
+ colspan = self.cols - c
94
+ if r + rowspan > self.rows:
95
+ self._report("V3", r, c, f"rowspan {rowspan} reaches past the last row")
96
+ rowspan = self.rows - r
97
+ if r < self.header_rows < r + rowspan:
98
+ self._report("V7", r, c, "a header cell spans into the body")
99
+ rowspan = self.header_rows - r
100
+ # V4: cut to the largest rectangle that overlaps nothing, columns first, then rows.
101
+ k = 1
102
+ while k < colspan and self.owner[r][c + k] is None:
103
+ k += 1
104
+ j = 1
105
+ while j < rowspan and self._free(r + j, r + j + 1, c, c + k):
106
+ j += 1
107
+ if (k, j) != (colspan, rowspan):
108
+ self._report("V4", r, c, f"span cut from {colspan}x{rowspan} to {k}x{j} to avoid an overlap")
109
+ colspan, rowspan = k, j
110
+ origin = _Origin(r, c, rowspan, colspan, text, fixed_cols, fixed_rows, source)
111
+ self.origins.append(origin)
112
+ self._cover(origin, r, r + rowspan, c, c + colspan)
113
+
114
+ # -- rows (spec 3.2) ---------------------------------------------------------
115
+
116
+ def _compact_fits(self, r: int, texts: list[str]) -> bool:
117
+ trial = copy.deepcopy(self)
118
+ used = 0
119
+ for c in range(self.cols):
120
+ if trial.owner[r][c] is None:
121
+ if used == len(texts):
122
+ return False
123
+ trial._place(r, c, texts[used])
124
+ used += 1
125
+ return all(t == "" for t in texts[used:])
126
+
127
+ def add_row(self, r: int, texts: list[str]) -> str:
128
+ """Place row r. Returns "grid", "compact" or "padded" (the row did not fit: V14)."""
129
+ n = len(texts)
130
+ if n != self.cols and self._compact_fits(r, texts):
131
+ remaining = iter(enumerate(texts))
132
+ for c in range(self.cols):
133
+ if self.owner[r][c] is None:
134
+ index, text = next(remaining)
135
+ self._place(r, c, text, index)
136
+ return "compact"
137
+ form = "grid"
138
+ if n != self.cols:
139
+ self._report("V14", r, None, f"row has {n} cells; it fits neither as a grid row "
140
+ f"({self.cols} cells) nor as a compact row")
141
+ texts = (texts + [""] * self.cols)[: self.cols]
142
+ form = "padded"
143
+ for c, text in enumerate(texts):
144
+ covering = self.owner[r][c]
145
+ if covering is not None:
146
+ if text not in ("", COLSPAN_MARKER, ROWSPAN_MARKER):
147
+ rule = "V5" if covering.row < r else "V6"
148
+ self._report(rule, r, c, f"a covered position holds text {text!r}; it is dropped")
149
+ continue
150
+ self._place(r, c, text, c if c < n else None)
151
+ return form
ltem/_syntax.py ADDED
@@ -0,0 +1,204 @@
1
+ """Line-level syntax: rows, delimiter rows, cell kinds and span attributes (spec Section 2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ DELIMITER_CELL = re.compile(r":?-+:?")
9
+ # A span attribute ends the cell and is the whole cell or follows a space or tab (spec 2.6).
10
+ SPAN_ATTRIBUTE = re.compile(r"(?:^|(?<=[ \t]))\{([cr]:[0-9]+(?:,[cr]:[0-9]+)*)\}$")
11
+ # Reserved table-identifier line (spec Section 9).
12
+ IDENTIFIER_ROW = re.compile(r"^ {0,3}\|[ \t]*#[ \t]+[A-Za-z_][A-Za-z0-9_]*[ \t]*\|[ \t]*$")
13
+ PIPE_LED = re.compile(r"^ {0,3}\|")
14
+ LINE_BREAK = re.compile(r"\\\\")
15
+
16
+ COLSPAN_MARKER = "<<"
17
+ ROWSPAN_MARKER = "^^"
18
+
19
+
20
+ def split_row_spans(line: str) -> list[tuple[str, int, int]]:
21
+ """Split a row into cells, as GFM does (spec 2.1), keeping where each cell is.
22
+
23
+ Each cell is (text, start, end). `text` is trimmed of spaces and tabs, and a pipe
24
+ preceded by a backslash becomes a literal pipe. `start` and `end` index `line` and
25
+ cover the cell's source without the spaces and tabs around it; for an empty cell
26
+ both are the end of its space between the pipes.
27
+ """
28
+ b, e = 0, len(line)
29
+ while b < e and line[b] in " \t":
30
+ b += 1
31
+ while e > b and line[e - 1] in " \t":
32
+ e -= 1
33
+ if b < e and line[b] == "|":
34
+ b += 1
35
+ if e > b and line[e - 1] == "|" and not (e - 2 >= b and line[e - 2] == "\\"):
36
+ e -= 1
37
+ cells: list[tuple[str, int, int]] = []
38
+ cur: list[str] = []
39
+ cell_start = i = b
40
+ while i < e:
41
+ ch = line[i]
42
+ if ch == "\\" and i + 1 < e and line[i + 1] == "|":
43
+ cur.append("|")
44
+ i += 2
45
+ continue
46
+ if ch == "|":
47
+ cells.append(_cell_span(line, cell_start, i, cur))
48
+ cur = []
49
+ cell_start = i + 1
50
+ else:
51
+ cur.append(ch)
52
+ i += 1
53
+ cells.append(_cell_span(line, cell_start, e, cur))
54
+ return cells
55
+
56
+
57
+ def _cell_span(line: str, start: int, end: int, chars: list[str]) -> tuple[str, int, int]:
58
+ while start < end and line[start] in " \t":
59
+ start += 1
60
+ while end > start and line[end - 1] in " \t":
61
+ end -= 1
62
+ return "".join(chars).strip(" \t"), start, end
63
+
64
+
65
+ def split_row(line: str) -> list[str]:
66
+ """Split a row into trimmed cell texts, as GFM does (spec 2.1).
67
+
68
+ A pipe preceded by a backslash does not split cells and becomes a literal pipe.
69
+ """
70
+ return [text for text, _, _ in split_row_spans(line)]
71
+
72
+
73
+ def indentation(line: str) -> int:
74
+ """Width of the leading spaces and tabs, with tab stops every 4 columns."""
75
+ width = 0
76
+ for ch in line:
77
+ if ch == " ":
78
+ width += 1
79
+ elif ch == "\t":
80
+ width += 4 - width % 4
81
+ else:
82
+ break
83
+ return width
84
+
85
+
86
+ _LIST_DASH = re.compile(r"^ {0,3}-[ \t]")
87
+
88
+
89
+ def is_delimiter_row(line: str) -> bool:
90
+ """A GFM delimiter row (spec 2.2).
91
+
92
+ It has at least one pipe, is indented less than 4 spaces, and does not begin a list
93
+ item: a line such as "- | -" is a list item, as in GFM.
94
+ """
95
+ if "|" not in line or indentation(line) >= 4 or _LIST_DASH.match(line):
96
+ return False
97
+ return all(DELIMITER_CELL.fullmatch(c) for c in split_row(line))
98
+
99
+
100
+ def is_upper_header_row(line: str) -> bool:
101
+ """Whether a line can be a header row above the last one (spec 2.3, 9).
102
+
103
+ It begins with a pipe, is not a reserved identifier line and does not look like a
104
+ delimiter row.
105
+ """
106
+ return bool(PIPE_LED.match(line)) and not IDENTIFIER_ROW.match(line) and not is_delimiter_row(line)
107
+
108
+
109
+ # Lines that start another block and so end a table body (spec 2.4).
110
+ _BODY_END = re.compile(
111
+ r"^ {0,3}(?:"
112
+ r">" # block quote
113
+ r"|#{1,6}(?:[ \t]|$)" # ATX heading
114
+ r"|`{3,}|~{3,}" # fenced code
115
+ r"|([-*_])(?:[ \t]*\1){2,}[ \t]*$" # thematic break
116
+ r"|(?:[-+*]|[0-9]{1,9}[.)])(?:[ \t]|$)" # list item
117
+ r")"
118
+ )
119
+
120
+
121
+ _SETEXT_UNDERLINE = re.compile(r"^ {0,3}(?:=+|-+)[ \t]*$")
122
+
123
+
124
+ def is_setext_underline(line: str) -> bool:
125
+ """A line of "=" or of "-" characters, which underlines a paragraph line as a heading."""
126
+ return bool(_SETEXT_UNDERLINE.match(line))
127
+
128
+
129
+ def starts_block(line: str) -> bool:
130
+ """Whether a line begins a block quote, ATX heading, fenced code, thematic break or list item."""
131
+ return bool(_BODY_END.match(line))
132
+
133
+
134
+ def ends_table_body(line: str) -> bool:
135
+ """Whether a table ends before this line (spec 2.4): blank, indented code, or another block."""
136
+ return not line.strip(" \t") or indentation(line) >= 4 or starts_block(line)
137
+
138
+
139
+ def alignment(cell: str) -> str | None:
140
+ left, right = cell.startswith(":"), cell.endswith(":")
141
+ if left and right:
142
+ return "center"
143
+ if right:
144
+ return "right"
145
+ if left:
146
+ return "left"
147
+ return None
148
+
149
+
150
+ @dataclass
151
+ class Attribute:
152
+ colspan: int | None
153
+ rowspan: int | None
154
+ problems: list[str]
155
+
156
+
157
+ def split_attribute(text: str) -> tuple[str, Attribute | None]:
158
+ """Remove a trailing span attribute from a content cell (spec 2.6).
159
+
160
+ Returns the remaining text and the attribute, or (text, None) if there is none.
161
+ """
162
+ m = SPAN_ATTRIBUTE.search(text)
163
+ if not m:
164
+ return text, None
165
+ colspan = rowspan = None
166
+ problems = []
167
+ for spec in m.group(1).split(","):
168
+ key, value = spec.split(":")
169
+ n = int(value)
170
+ if n < 1:
171
+ problems.append(f"span value must be a positive integer, got {value}")
172
+ n = 1
173
+ if key == "c":
174
+ if colspan is not None:
175
+ problems.append("'c' appears more than once")
176
+ continue
177
+ colspan = n
178
+ else:
179
+ if rowspan is not None:
180
+ problems.append("'r' appears more than once")
181
+ continue
182
+ rowspan = n
183
+ return text[: m.start()].rstrip(" \t"), Attribute(colspan, rowspan, problems)
184
+
185
+
186
+ # The LTEM escapes of spec 4.3, as written in cell source.
187
+ _ESCAPED_MARKER = re.compile(r"^\\(<<|\^\^)$")
188
+ _ESCAPED_ATTRIBUTE = re.compile(r"(?:^|(?<=[ \t]))\\(\{[cr]:[0-9]+(?:,[cr]:[0-9]+)*\})$")
189
+
190
+
191
+ def unescape_ltem(line: str) -> str:
192
+ """Remove the backslash of an LTEM escape (spec 4.3), as GFM does for \\|."""
193
+ m = _ESCAPED_MARKER.match(line)
194
+ if m:
195
+ return m.group(1)
196
+ return _ESCAPED_ATTRIBUTE.sub(r"\1", line)
197
+
198
+
199
+ def cell_lines(text: str) -> str:
200
+ """Turn cell source into model text (spec Section 7).
201
+
202
+ Split at each \\\\, trim each line and remove LTEM escapes.
203
+ """
204
+ return "\n".join(unescape_ltem(part.strip(" \t")) for part in LINE_BREAK.split(text))
ltem/cli.py ADDED
@@ -0,0 +1,118 @@
1
+ """Command-line interface.
2
+
3
+ ltem parse [FILE] table models of all tables, as JSON
4
+ ltem render [FILE] HTML of all tables
5
+ ltem validate [FILE] validation problems; exit status 1 if there are errors
6
+ ltem fmt [FILE] rewrite the input with every table in canonical form
7
+ ltem write [FILE] canonical LTEM of table models given as JSON
8
+ ltem from-html [FILE] [--header-rows N] [--plain] convert HTML tables to LTEM
9
+
10
+ Input is read from FILE or standard input. `parse`, `render`, `validate --json` and
11
+ `write` follow the command-line protocol of the LTEM conformance tests.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+
20
+ from .fromhtml import from_html
21
+ from .html import render_html
22
+ from .model import Table
23
+ from .parser import find_tables
24
+ from .writer import write
25
+
26
+
27
+ def _read(path: str | None) -> str:
28
+ """Read the input with every line ending turned into "\\n", for stdin as for files."""
29
+ if path in (None, "-"):
30
+ text = sys.stdin.read()
31
+ else:
32
+ with open(path, encoding="utf-8", newline="") as f:
33
+ text = f.read()
34
+ return text.replace("\r\n", "\n").replace("\r", "\n")
35
+
36
+
37
+ def _fmt(text: str) -> str:
38
+ """Replace each table in the text by its canonical form, keeping everything else.
39
+
40
+ The table keeps the indentation of its first line, so a table in a list item stays there.
41
+ """
42
+ lines = text.split("\n")
43
+ for result in reversed(find_tables(text)):
44
+ assert result.table is not None
45
+ first = lines[result.start]
46
+ indent = first[: len(first) - len(first.lstrip(" \t"))]
47
+ lines[result.start:result.end] = [indent + line for line in write(result.table).split("\n")]
48
+ return "\n".join(lines)
49
+
50
+
51
+ def _write(text: str) -> int:
52
+ """Print the canonical form of table models: one model, or {"tables": [...]}."""
53
+ try:
54
+ data = json.loads(text)
55
+ items = data["tables"] if isinstance(data, dict) and "tables" in data and "version" not in data else [data]
56
+ tables = [Table.from_dict(d) for d in items]
57
+ except (ValueError, KeyError, TypeError) as e:
58
+ print(f"ltem: error: not a table model: {e}", file=sys.stderr)
59
+ return 2
60
+ for n, table in enumerate(tables, 1):
61
+ problems = table.check()
62
+ if problems:
63
+ print(f"ltem: table {n}: invalid table model: {'; '.join(problems)}", file=sys.stderr)
64
+ return 1
65
+ print("\n\n".join(write(t) for t in tables))
66
+ return 0
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ p = argparse.ArgumentParser(prog="ltem", description="LTEM tables: parse, render, validate, format.")
71
+ sub = p.add_subparsers(dest="command", required=True)
72
+ for name in ("parse", "render", "validate", "fmt", "write", "from-html"):
73
+ sp = sub.add_parser(name)
74
+ sp.add_argument("file", nargs="?")
75
+ if name == "validate":
76
+ sp.add_argument("--json", action="store_true", help="print problems as JSON")
77
+ if name == "from-html":
78
+ sp.add_argument("--header-rows", type=int, default=None,
79
+ help="number of header rows (default: <thead>, leading <th> rows, or 1; "
80
+ "0: no header)")
81
+ sp.add_argument("--plain", action="store_true", help="cell text only, without Markdown formatting")
82
+ args = p.parse_args(argv)
83
+ text = _read(args.file)
84
+ if args.command == "write":
85
+ return _write(text)
86
+ if args.command == "from-html":
87
+ converted = from_html(text, args.header_rows, "plain" if args.plain else "markdown")
88
+ for n, item in enumerate(converted, 1):
89
+ for w in item.warnings:
90
+ print(f"table {n}: {w}", file=sys.stderr)
91
+ print("\n\n".join(write(item.table) for item in converted))
92
+ return 0
93
+ results = find_tables(text)
94
+
95
+ if args.command == "parse":
96
+ json.dump({"tables": [r.table.to_dict() for r in results]}, sys.stdout, ensure_ascii=False, indent=2)
97
+ print()
98
+ return 0
99
+ if args.command == "render":
100
+ print("\n".join(render_html(r.table) for r in results))
101
+ return 0
102
+ if args.command == "validate":
103
+ problems = [pr for r in results for pr in r.problems]
104
+ if args.json:
105
+ json.dump({"errors": [{**pr.to_dict(), "severity": pr.severity} for pr in problems]}, sys.stdout,
106
+ indent=2)
107
+ print()
108
+ else:
109
+ for n, r in enumerate(results, 1):
110
+ for pr in r.problems:
111
+ print(f"table {n}: {pr}")
112
+ return 1 if any(pr.severity == "error" for pr in problems) else 0
113
+ sys.stdout.write(_fmt(text))
114
+ return 0
115
+
116
+
117
+ if __name__ == "__main__":
118
+ sys.exit(main())