ltem-core 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .venv/
5
+ node_modules/
6
+ target/
7
+ dist/
8
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EurekaAI Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: ltem-core
3
+ Version: 0.1.0
4
+ Summary: LTEM (LLM-friendly Table-Enhanced Markdown): Markdown tables with merged cells and multi-row headers
5
+ Author: Sangkeun Jung
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: colspan,llm,ltem,markdown,rowspan,table
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
14
+ Requires-Python: >=3.10
15
+ Provides-Extra: markdown
16
+ Requires-Dist: markdown-it-py>=3; extra == 'markdown'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ltem (Python)
20
+
21
+ Reference Python implementation of [LTEM](../../../README.md): Markdown pipe tables with merged cells and multi-row headers. It follows the LTEM specification, version 1.0.
22
+
23
+ Status: version 0.1.0, the first release, for LTEM 1.0. The API may change before version 1.0.0.
24
+
25
+ ## Install
26
+
27
+ ```
28
+ pip install ltem-core # no dependencies
29
+ pip install "ltem-core[markdown]" # also renders inline Markdown in cells (uses markdown-it-py)
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ import ltem
36
+
37
+ result = ltem.parse_table("""\
38
+ | Region | Revenue | << |
39
+ | ^^ | Q1 | Q2 |
40
+ |--------|---------|-----|
41
+ | Asia | 100 | 120 |""")
42
+
43
+ table = result.table # the table model (see ast/ in the LTEM repository)
44
+ table.to_dict() # JSON-ready dict
45
+ ltem.render_html(table) # <table> with colspan and rowspan
46
+ ltem.write(table) # canonical LTEM
47
+ result.problems # validation problems, if any
48
+
49
+ for item in ltem.from_html(html): # HTML tables -> table models
50
+ print(ltem.write(item.table), item.warnings)
51
+
52
+ text, sources = ltem.write_with_sources(table) # canonical LTEM and where each cell is
53
+ ```
54
+
55
+ ### Where cells are
56
+
57
+ `ParseResult.sources` and `write_with_sources` give, for each cell of the table and in the same order, a `CellSource(row, col, rowspan, colspan, line, start, end)`:
58
+
59
+ - `line` counts lines from 0: lines of the parsed text for `parse_table` and `find_tables`, lines of the table for `write_with_sources`.
60
+ - `start` and `end` index that line in Python string units (code points). They cover the cell's source without the spaces and tabs around it, including any span attribute and escapes.
61
+ - For an empty cell, `start` equals `end`, at the end of its space between the pipes. A cell added because its row was short (rule V14) has `start == end ==` the length of its line.
62
+
63
+ Without `markdown-it-py`, cell text is HTML-escaped and backslash escapes are applied, but emphasis, links and code are shown as written.
64
+
65
+ `ltem.find_tables(markdown)` finds every LTEM table in a Markdown document. It is a light scan that skips code blocks. It is not a full Markdown parser.
66
+
67
+ ## Command line
68
+
69
+ ```
70
+ ltem parse [FILE] # table models as JSON
71
+ ltem render [FILE] # HTML of each table
72
+ ltem validate [FILE] [--json] # validation problems; exit status 1 if any
73
+ ltem fmt [FILE] # rewrite every table in canonical form
74
+ ltem from-html [FILE] [--header-rows N] [--plain] # convert HTML tables to LTEM
75
+ ```
76
+
77
+ `from_html(html, header_rows=None, inline="markdown")` options:
78
+
79
+ - `header_rows`: the number of header rows. By default it is the `<thead>` rows; without `<thead>`, the leading rows made only of `<th>` cells; and if there are none, the first row (with a warning). With `0`, the table has no header: it gets one header row of empty cells and every HTML row becomes a body row (spec Section 11).
80
+ - `inline`: `"markdown"` keeps emphasis, code, links and images as Markdown; `"plain"` keeps text only. Either way, text is escaped so that it reads literally.
81
+
82
+ `<th>` cells below the header rows become ordinary cells, since LTEM 1.0 has no row headers; the converter reports this once per table in `warnings`.
83
+
84
+ `from_html` forms the grid as browsers do: cells take the next free position, `rowspan="0"` reaches the end of its row group, and spans stop at the edge of `<thead>`, `<tbody>` or `<tfoot>`. The header rows are the `<thead>` rows; without `<thead>`, the leading rows made only of `<th>` cells; and if there are none, the first row. Cell content becomes inline Markdown: `<br>` and block elements become line breaks, and `<strong>`, `<em>`, `<code>`, `<a>`, `<img>`, lists, `<sup>` and `<sub>` are kept. A table inside a cell is flattened to text with a warning, since LTEM 1.0 has no nested tables.
85
+
86
+ ## Development
87
+
88
+ From `ltem-lib/python`:
89
+
90
+ ```
91
+ uv sync --all-packages
92
+ uv run --directory ltem pytest
93
+ ```
94
+
95
+ The tests run the conformance cases in [`conformance/`](../../../conformance/).
@@ -0,0 +1,77 @@
1
+ # ltem (Python)
2
+
3
+ Reference Python implementation of [LTEM](../../../README.md): Markdown pipe tables with merged cells and multi-row headers. It follows the LTEM specification, version 1.0.
4
+
5
+ Status: version 0.1.0, the first release, for LTEM 1.0. The API may change before version 1.0.0.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install ltem-core # no dependencies
11
+ pip install "ltem-core[markdown]" # also renders inline Markdown in cells (uses markdown-it-py)
12
+ ```
13
+
14
+ ## Use
15
+
16
+ ```python
17
+ import ltem
18
+
19
+ result = ltem.parse_table("""\
20
+ | Region | Revenue | << |
21
+ | ^^ | Q1 | Q2 |
22
+ |--------|---------|-----|
23
+ | Asia | 100 | 120 |""")
24
+
25
+ table = result.table # the table model (see ast/ in the LTEM repository)
26
+ table.to_dict() # JSON-ready dict
27
+ ltem.render_html(table) # <table> with colspan and rowspan
28
+ ltem.write(table) # canonical LTEM
29
+ result.problems # validation problems, if any
30
+
31
+ for item in ltem.from_html(html): # HTML tables -> table models
32
+ print(ltem.write(item.table), item.warnings)
33
+
34
+ text, sources = ltem.write_with_sources(table) # canonical LTEM and where each cell is
35
+ ```
36
+
37
+ ### Where cells are
38
+
39
+ `ParseResult.sources` and `write_with_sources` give, for each cell of the table and in the same order, a `CellSource(row, col, rowspan, colspan, line, start, end)`:
40
+
41
+ - `line` counts lines from 0: lines of the parsed text for `parse_table` and `find_tables`, lines of the table for `write_with_sources`.
42
+ - `start` and `end` index that line in Python string units (code points). They cover the cell's source without the spaces and tabs around it, including any span attribute and escapes.
43
+ - For an empty cell, `start` equals `end`, at the end of its space between the pipes. A cell added because its row was short (rule V14) has `start == end ==` the length of its line.
44
+
45
+ Without `markdown-it-py`, cell text is HTML-escaped and backslash escapes are applied, but emphasis, links and code are shown as written.
46
+
47
+ `ltem.find_tables(markdown)` finds every LTEM table in a Markdown document. It is a light scan that skips code blocks. It is not a full Markdown parser.
48
+
49
+ ## Command line
50
+
51
+ ```
52
+ ltem parse [FILE] # table models as JSON
53
+ ltem render [FILE] # HTML of each table
54
+ ltem validate [FILE] [--json] # validation problems; exit status 1 if any
55
+ ltem fmt [FILE] # rewrite every table in canonical form
56
+ ltem from-html [FILE] [--header-rows N] [--plain] # convert HTML tables to LTEM
57
+ ```
58
+
59
+ `from_html(html, header_rows=None, inline="markdown")` options:
60
+
61
+ - `header_rows`: the number of header rows. By default it is the `<thead>` rows; without `<thead>`, the leading rows made only of `<th>` cells; and if there are none, the first row (with a warning). With `0`, the table has no header: it gets one header row of empty cells and every HTML row becomes a body row (spec Section 11).
62
+ - `inline`: `"markdown"` keeps emphasis, code, links and images as Markdown; `"plain"` keeps text only. Either way, text is escaped so that it reads literally.
63
+
64
+ `<th>` cells below the header rows become ordinary cells, since LTEM 1.0 has no row headers; the converter reports this once per table in `warnings`.
65
+
66
+ `from_html` forms the grid as browsers do: cells take the next free position, `rowspan="0"` reaches the end of its row group, and spans stop at the edge of `<thead>`, `<tbody>` or `<tfoot>`. The header rows are the `<thead>` rows; without `<thead>`, the leading rows made only of `<th>` cells; and if there are none, the first row. Cell content becomes inline Markdown: `<br>` and block elements become line breaks, and `<strong>`, `<em>`, `<code>`, `<a>`, `<img>`, lists, `<sup>` and `<sub>` are kept. A table inside a cell is flattened to text with a warning, since LTEM 1.0 has no nested tables.
67
+
68
+ ## Development
69
+
70
+ From `ltem-lib/python`:
71
+
72
+ ```
73
+ uv sync --all-packages
74
+ uv run --directory ltem pytest
75
+ ```
76
+
77
+ The tests run the conformance cases in [`conformance/`](../../../conformance/).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ltem-core"
7
+ version = "0.1.0"
8
+ description = "LTEM (LLM-friendly Table-Enhanced Markdown): Markdown tables with merged cells and multi-row headers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Sangkeun Jung" }]
14
+ keywords = ["markdown", "table", "colspan", "rowspan", "llm", "ltem"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Text Processing :: Markup :: Markdown",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ markdown = ["markdown-it-py>=3"]
26
+
27
+ [project.scripts]
28
+ ltem = "ltem.cli:main"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/ltem"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -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
@@ -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))