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 |
+
+
+
+
+
| Feature | Status | Notes |
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, footnote, and a+b.
+
+
+
+
+
| Left | Right | Center |
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('')
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