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 +51 -0
- ltem/__main__.py +4 -0
- ltem/_grid.py +151 -0
- ltem/_syntax.py +204 -0
- ltem/cli.py +118 -0
- ltem/fromhtml.py +455 -0
- ltem/html.py +78 -0
- ltem/model.py +143 -0
- ltem/parser.py +143 -0
- ltem/py.typed +0 -0
- ltem/writer.py +72 -0
- ltem_core-0.1.0.dist-info/METADATA +95 -0
- ltem_core-0.1.0.dist-info/RECORD +16 -0
- ltem_core-0.1.0.dist-info/WHEEL +4 -0
- ltem_core-0.1.0.dist-info/entry_points.txt +2 -0
- ltem_core-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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())
|