diff --git a/README.md b/README.md index 424a02c..9fd1a26 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ xhtmlmd is largely implemented using AI, except for the tests. The tests are lar ## Implemented syntax - Core block syntax: paragraphs, ATX/setext headings, thematic breaks, block quotes, ordered/unordered lists, indented code, raw HTML, link reference definitions. -- Tables: GFM/PHP Extra pipe tables with alignment, and Pandoc grid tables with alignment, headerless tables, block cell content, row spans, column spans, and footers. +- Tables: GFM/PHP Extra pipe tables with alignment, and Pandoc grid tables with alignment, headerless tables, block cell content, row spans, column spans, footers, and pipe-table column widths from separator dash counts. - GFM: task lists, `~~x~~` strikethrough, angle and bare autolinks, plus opt-in tagfiltering. - Code: backtick/tilde fenced code blocks, info strings, and Pandoc-style code attributes. - HTML-in-Markdown: block containers opened with `markdown="1"`; the control attribute is stripped, indented code blocks are disabled inside the container, and fenced code is the code-block syntax there. @@ -66,6 +66,16 @@ html_for_katex = to_xhtml(r"\(x^2\)", math="on") html_with_dollars = to_xhtml("$x$", math="dollars") ``` +### Table column widths + +Pipe tables can state relative column widths through the separator row: `|------|--|` renders as a `` giving the columns 75% and 25%. This is on by default; pass `table_widths=False` (CLI `--no-table-widths`) to turn it off. + +```python +html = to_xhtml("| a | b |\n|------|--|\n| x | y |\n") +``` + +Each width is the separator cell's character count (alignment colons included) over the row's total, truncated to a whole percent. This matches what Pandoc's HTML writer emits, and Pandoc's HTML reader turns it back into column widths. A separator row whose cells are all the same length sets no widths, so the browser lays the table out as usual. + ### Markdown rewriting `rewrite` changes recognized Markdown constructs without regenerating the rest of the document. A callback returns `None` to leave a construct alone, a string to replace the whole construct, or a dict to replace one of its named fields. diff --git a/docs/DIALECT.md b/docs/DIALECT.md index 86aa494..0cbccf9 100644 --- a/docs/DIALECT.md +++ b/docs/DIALECT.md @@ -3,6 +3,7 @@ When Markdown extensions disagree, this crate chooses the behavior closest to Pandoc unless that would conflict with GFM for a GFM-named feature. - Pipe tables follow PHP Markdown Extra/Pandoc/GFM alignment markers. Header rows are required. +- Pipe-table column widths are on by default (`table_widths=False` / `--no-table-widths` disables them) and follow Pandoc: each separator cell's length, alignment colons included, over the row's total, truncated to a whole percent and emitted as `` of ``. A separator row whose cells are all the same length sets no widths, and grid tables never do. - Fenced divs follow Pandoc: an opening fence has at least three colons and attributes or a single class word; a closing fence is a colon-only line of at least three colons. - Attribute syntax is kramdown/Pandoc-compatible: `#id`, `.class`, `key="value"`, ALDs, block IALs, and span IALs. A braced group is an attribute list only when it starts with `:`, `#`, `.`, or a `key=value` pair; bare words in braces stay literal text. Key/value pairs override earlier keys; classes accumulate. Trailing lists attach to emphasis, strong, and strikethrough as well as the bracket constructs, and an IAL line directly after a table attaches to the table. - Definition lists follow PHP Markdown Extra/Pandoc: one-line terms with one or more `:` or `~` definitions. diff --git a/docs/sample.html b/docs/sample.html index f5040cb..1c7b361 100644 --- a/docs/sample.html +++ b/docs/sample.html @@ -79,6 +79,11 @@

Tables

| HTML | ready | raw or markdown-enabled | +++++ diff --git a/python/xhtmlmd/__init__.py b/python/xhtmlmd/__init__.py index 66af028..e5e1d7e 100644 --- a/python/xhtmlmd/__init__.py +++ b/python/xhtmlmd/__init__.py @@ -3,10 +3,10 @@ __all__ = ["to_xhtml", "render", "blocks", "rewrite"] -def to_xhtml(markdown: str, *, math: str = "brackets", tagfilter: bool = False, balance: bool = False, callbacks: dict | None = None, +def to_xhtml(markdown: str, *, math: str = "brackets", tagfilter: bool = False, balance: bool = False, table_widths: bool = True, callbacks: dict | None = None, max_inline_depth: int | None = None, max_block_depth: int | None = None, max_link_paren_depth: int | None = None) -> str: "Render Markdown to an XHTML fragment." - return _to_xhtml(markdown, math=math, tagfilter=tagfilter, balance=balance, callbacks=callbacks, max_inline_depth=max_inline_depth, + return _to_xhtml(markdown, math=math, tagfilter=tagfilter, balance=balance, table_widths=table_widths, callbacks=callbacks, max_inline_depth=max_inline_depth, max_block_depth=max_block_depth, max_link_paren_depth=max_link_paren_depth) diff --git a/python/xhtmlmd/__main__.py b/python/xhtmlmd/__main__.py index dac345f..ab50055 100644 --- a/python/xhtmlmd/__main__.py +++ b/python/xhtmlmd/__main__.py @@ -3,20 +3,22 @@ from . import to_xhtml -USAGE = ("usage: xhtmlmd [--math=off|on|brackets|dollars] [--balance] [file.md]\n\n" +USAGE = ("usage: xhtmlmd [--math=off|on|brackets|dollars] [--balance] [--no-table-widths] [file.md]\n\n" "Reads Markdown from a file or stdin and writes XHTML fragment output. Math defaults to brackets.\n" - "--balance closes unclosed raw HTML tags and drops stray closing tags.") + "--balance closes unclosed raw HTML tags and drops stray closing tags.\n" + "--no-table-widths disables column widths from pipe-table separator dash counts.") def main(argv=None): argv = sys.argv[1:] if argv is None else argv - math, balance, file = "brackets", False, None + math, balance, table_widths, file = "brackets", False, True, None for arg in argv: if arg in ("--math=off", "--math=on", "--math=brackets", "--math=dollars"): math = arg.split("=", 1)[1] elif arg == "--balance": balance = True + elif arg == "--no-table-widths": table_widths = False elif arg in ("-h", "--help"): print(USAGE); return elif arg.startswith("--"): print(f"unknown option: {arg}", file=sys.stderr); sys.exit(2) else: file = arg src = open(file, encoding="utf-8").read() if file else sys.stdin.read() - sys.stdout.write(to_xhtml(src, math=math, balance=balance)) + sys.stdout.write(to_xhtml(src, math=math, balance=balance, table_widths=table_widths)) if __name__ == "__main__": main() diff --git a/src/ast.rs b/src/ast.rs index d04a147..4c43a5f 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -175,6 +175,7 @@ pub enum Block { Table { attrs: Attr, aligns: Vec, + widths: Vec, head: Vec, rows: Vec, foot: Vec, diff --git a/src/block.rs b/src/block.rs index 02e2679..05c72e1 100644 --- a/src/block.rs +++ b/src/block.rs @@ -173,6 +173,7 @@ enum DraftBlock { Table { attrs: Attr, aligns: Vec, + widths: Vec, head: Vec, rows: Vec, foot: Vec, @@ -305,12 +306,14 @@ fn finalize_block(block: DraftBlock, ctx: &InlineContext<'_>) -> Block { DraftBlock::Table { attrs, aligns, + widths, head, rows, foot, } => Block::Table { attrs, aligns, + widths, head: finalize_table_rows(head, ctx), rows: finalize_table_rows(rows, ctx), foot: finalize_table_rows(foot, ctx), @@ -797,6 +800,7 @@ enum BuildKind { Table { attrs: Attr, aligns: Vec, + widths: Vec, head: Vec, rows: Vec, foot: Vec, @@ -1307,12 +1311,17 @@ impl<'a> ContainerBuilder<'a> { let Some(header) = split_table_row(&header_line) else { return false; }; - let Some(aligns) = parse_table_separator(line) else { + let Some((aligns, seps)) = parse_table_separator(line) else { return false; }; if header.len() != aligns.len() { return false; } + let widths = if self.options.table_widths { + separator_widths(&seps) + } else { + Vec::new() + }; let head = header .into_iter() .map(|cell| cell.trim().to_string()) @@ -1321,6 +1330,7 @@ impl<'a> ContainerBuilder<'a> { attrs: Attr::default(), head: vec![draft_inline_table_row(head, &aligns)], aligns, + widths, rows: Vec::new(), foot: Vec::new(), trim_leading_body_pipe: header_line.trim_start().starts_with('|'), @@ -2243,6 +2253,7 @@ impl<'a> ContainerBuilder<'a> { BuildKind::Table { attrs, aligns, + widths, head, rows, foot, @@ -2250,6 +2261,7 @@ impl<'a> ContainerBuilder<'a> { } => vec![DraftBlock::Table { attrs: attrs.clone(), aligns: aligns.clone(), + widths: widths.clone(), head: head.clone(), rows: rows.clone(), foot: foot.clone(), @@ -3632,6 +3644,7 @@ fn parse_grid_table(lines: &[String], parser: &mut Parser, depth: usize) -> Opti Some(DraftBlock::Table { attrs: Attr::default(), aligns, + widths: Vec::new(), head, rows, foot, @@ -3898,9 +3911,10 @@ fn raw_table_cells(line: &str) -> Vec { cells } -fn parse_table_separator(line: &str) -> Option> { +fn parse_table_separator(line: &str) -> Option<(Vec, Vec)> { let cells = split_table_row(line)?; let mut aligns = Vec::new(); + let mut lens = Vec::new(); for cell in cells { let c = cell.trim(); let left = c.starts_with(':'); @@ -3915,8 +3929,17 @@ fn parse_table_separator(line: &str) -> Option> { (false, true) => Align::Right, _ => Align::None, }); + lens.push(c.len()); + } + Some((aligns, lens)) +} + +fn separator_widths(lens: &[usize]) -> Vec { + if lens.iter().all(|l| *l == lens[0]) { + return Vec::new(); } - Some(aligns) + let total: usize = lens.iter().sum(); + lens.iter().map(|l| *l as f64 / total as f64).collect() } fn paragraph_interrupts(line: &str) -> bool { diff --git a/src/lib.rs b/src/lib.rs index 6bb3fb1..96d4245 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub struct Options { pub math: MathMode, pub tagfilter: bool, pub balance: bool, + pub table_widths: bool, pub max_inline_depth: usize, pub max_block_depth: usize, pub max_link_paren_depth: usize, @@ -47,6 +48,7 @@ impl Default for Options { math: MathMode::Brackets, tagfilter: false, balance: false, + table_widths: true, max_inline_depth: 64, max_block_depth: 128, max_link_paren_depth: 32, diff --git a/src/python.rs b/src/python.rs index 4d4e672..2fd881a 100644 --- a/src/python.rs +++ b/src/python.rs @@ -16,6 +16,7 @@ use crate::{MathMode, Options}; math = "brackets", tagfilter = false, balance = false, + table_widths = true, callbacks = None, max_inline_depth = None, max_block_depth = None, @@ -26,6 +27,7 @@ fn to_xhtml( math: &str, tagfilter: bool, balance: bool, + table_widths: bool, callbacks: Option>, max_inline_depth: Option, max_block_depth: Option, @@ -35,6 +37,7 @@ fn to_xhtml( math: parse_math_mode(math)?, tagfilter, balance, + table_widths, ..Options::default() }; if let Some(depth) = max_inline_depth { @@ -400,6 +403,7 @@ fn block_node<'py>(py: Python<'py>, block: &Block) -> PyResult(py: Python<'py>, block: &Block) -> PyResult>(), )?; + d.set_item("widths", widths.clone())?; d.set_item( "head_cells", head.iter().map(|row| row.cells.len()).sum::(), diff --git a/src/render.rs b/src/render.rs index 721e15b..e598138 100644 --- a/src/render.rs +++ b/src/render.rs @@ -130,10 +130,11 @@ impl<'a> Renderer<'a> { Block::Table { attrs, aligns, + widths, head, rows, foot, - } => self.table(attrs, aligns, head, rows, foot, out), + } => self.table(attrs, aligns, widths, head, rows, foot, out), Block::Div { attrs, children } => { out.push_str(" Renderer<'a> { &mut self, attrs: &Attr, aligns: &[Align], + widths: &[f64], head: &[TableRow], rows: &[TableRow], foot: &[TableRow], @@ -231,6 +233,15 @@ impl<'a> Renderer<'a> { out.push_str("\n"); + if !widths.is_empty() { + out.push_str("\n"); + for w in widths { + out.push_str("\n"); + } + out.push_str("\n"); + } if !head.is_empty() { out.push_str("\n"); for row in head { diff --git a/tests/fixtures/dialect.xhtml b/tests/fixtures/dialect.xhtml index 21a7619..6c06918 100644 --- a/tests/fixtures/dialect.xhtml +++ b/tests/fixtures/dialect.xhtml @@ -1,6 +1,11 @@

Title

A paragraph with em, strong, gone, H2O, a link, code, https://example.org, footnote1, and a+b.

FeatureStatusNotes
+++++ diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 766dee5..d9e484d 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -134,5 +134,5 @@ def normalize_html(s): @pytest.mark.parametrize("name,example,section,md,html", _CASES, ids=[f"{c[0]}:{c[1]}:{c[2]}" for c in _CASES]) def test_conformance(name, example, section, md, html): - actual = to_xhtml(md, math="off") + actual = to_xhtml(md, math="off", table_widths=False) assert normalize_html(html) == normalize_html(actual) diff --git a/tests/test_focused.py b/tests/test_focused.py index 8b58a09..19c80ed 100644 --- a/tests/test_focused.py +++ b/tests/test_focused.py @@ -132,3 +132,14 @@ def test_table_ial_line_attaches(): html = to_xhtml('a|b\n-|-\n1|2\n{: .c}\n') assert html.startswith('
LeftRightCenter
') assert '{: .c}' not in html + +def test_table_widths_from_separator_dashes(): + table = "| a | b |\n|------|--|\n| x | y |\n" + html = to_xhtml(table) + assert '
\n\n\n\n\n' in html + assert "" not in to_xhtml(table, table_widths=False) + html = to_xhtml("| a | b |\n|:---|----:|\n| x | y |\n") + assert '\n' in html + assert "" not in to_xhtml("| a | b |\n|---|---|\n| x | y |\n") + grid = "+---+----+\n| a | b |\n+===+====+\n| 1 | 2 |\n+---+----+\n" + assert "" not in to_xhtml(grid) diff --git a/tests/test_python.py b/tests/test_python.py index d627ddc..522ee81 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -239,3 +239,18 @@ def test_max_link_paren_depth_is_honored(): shallow = "[a](((x)))" assert "' in default_html + return None + + to_xhtml("| a | b |\n|------|--|\n| x | y |\n", callbacks={"table": table}) + assert calls == [[0.75, 0.25]] + res = subprocess.run(["xhtmlmd"], input="| a | b |\n|------|--|\n| x | y |\n", text=True, capture_output=True, check=True) + assert '' in res.stdout + res = subprocess.run(["xhtmlmd", "--no-table-widths"], input="| a | b |\n|------|--|\n| x | y |\n", text=True, capture_output=True, check=True) + assert "" not in res.stdout