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.
- ltem_core-0.1.0/.gitignore +8 -0
- ltem_core-0.1.0/LICENSE +21 -0
- ltem_core-0.1.0/PKG-INFO +95 -0
- ltem_core-0.1.0/README.md +77 -0
- ltem_core-0.1.0/pyproject.toml +34 -0
- ltem_core-0.1.0/src/ltem/__init__.py +51 -0
- ltem_core-0.1.0/src/ltem/__main__.py +4 -0
- ltem_core-0.1.0/src/ltem/_grid.py +151 -0
- ltem_core-0.1.0/src/ltem/_syntax.py +204 -0
- ltem_core-0.1.0/src/ltem/cli.py +118 -0
- ltem_core-0.1.0/src/ltem/fromhtml.py +455 -0
- ltem_core-0.1.0/src/ltem/html.py +78 -0
- ltem_core-0.1.0/src/ltem/model.py +143 -0
- ltem_core-0.1.0/src/ltem/parser.py +143 -0
- ltem_core-0.1.0/src/ltem/py.typed +0 -0
- ltem_core-0.1.0/src/ltem/writer.py +72 -0
- ltem_core-0.1.0/tests/conftest.py +28 -0
- ltem_core-0.1.0/tests/test_conformance.py +43 -0
- ltem_core-0.1.0/tests/test_fromhtml.py +282 -0
- ltem_core-0.1.0/tests/test_model_and_cli.py +123 -0
- ltem_core-0.1.0/tests/test_sources.py +58 -0
- ltem_core-0.1.0/tests/test_writer.py +107 -0
ltem_core-0.1.0/LICENSE
ADDED
|
@@ -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.
|
ltem_core-0.1.0/PKG-INFO
ADDED
|
@@ -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,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))
|