Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<colgroup>` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/DIALECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<colgroup>` of `<col style="width: N%" />`. 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.
Expand Down
5 changes: 5 additions & 0 deletions docs/sample.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ <h2>Tables</h2>
| HTML | ready | raw or markdown-enabled |
</code></pre>
<table>
<colgroup>
<col style="width: 37%" />
<col style="width: 33%" />
<col style="width: 29%" />
</colgroup>
<thead>
<tr><th align="left">Feature</th><th align="center">Status</th><th align="right">Notes</th></tr>
</thead>
Expand Down
4 changes: 2 additions & 2 deletions python/xhtmlmd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
10 changes: 6 additions & 4 deletions python/xhtmlmd/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <colgroup> 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()
1 change: 1 addition & 0 deletions src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ pub enum Block {
Table {
attrs: Attr,
aligns: Vec<Align>,
widths: Vec<f64>,
head: Vec<TableRow>,
rows: Vec<TableRow>,
foot: Vec<TableRow>,
Expand Down
29 changes: 26 additions & 3 deletions src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ enum DraftBlock {
Table {
attrs: Attr,
aligns: Vec<Align>,
widths: Vec<f64>,
head: Vec<DraftTableRow>,
rows: Vec<DraftTableRow>,
foot: Vec<DraftTableRow>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -797,6 +800,7 @@ enum BuildKind {
Table {
attrs: Attr,
aligns: Vec<Align>,
widths: Vec<f64>,
head: Vec<DraftTableRow>,
rows: Vec<DraftTableRow>,
foot: Vec<DraftTableRow>,
Expand Down Expand Up @@ -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())
Expand All @@ -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('|'),
Expand Down Expand Up @@ -2243,13 +2253,15 @@ impl<'a> ContainerBuilder<'a> {
BuildKind::Table {
attrs,
aligns,
widths,
head,
rows,
foot,
..
} => vec![DraftBlock::Table {
attrs: attrs.clone(),
aligns: aligns.clone(),
widths: widths.clone(),
head: head.clone(),
rows: rows.clone(),
foot: foot.clone(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3898,9 +3911,10 @@ fn raw_table_cells(line: &str) -> Vec<String> {
cells
}

fn parse_table_separator(line: &str) -> Option<Vec<Align>> {
fn parse_table_separator(line: &str) -> Option<(Vec<Align>, Vec<usize>)> {
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(':');
Expand All @@ -3915,8 +3929,17 @@ fn parse_table_separator(line: &str) -> Option<Vec<Align>> {
(false, true) => Align::Right,
_ => Align::None,
});
lens.push(c.len());
}
Some((aligns, lens))
}

fn separator_widths(lens: &[usize]) -> Vec<f64> {
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 {
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ fn to_xhtml(
math: &str,
tagfilter: bool,
balance: bool,
table_widths: bool,
callbacks: Option<Bound<'_, PyDict>>,
max_inline_depth: Option<usize>,
max_block_depth: Option<usize>,
Expand All @@ -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 {
Expand Down Expand Up @@ -400,6 +403,7 @@ fn block_node<'py>(py: Python<'py>, block: &Block) -> PyResult<Bound<'py, PyDict
Block::Table {
attrs,
aligns,
widths,
head,
rows,
foot,
Expand All @@ -409,6 +413,7 @@ fn block_node<'py>(py: Python<'py>, block: &Block) -> PyResult<Bound<'py, PyDict
"aligns",
aligns.iter().map(ToString::to_string).collect::<Vec<_>>(),
)?;
d.set_item("widths", widths.clone())?;
d.set_item(
"head_cells",
head.iter().map(|row| row.cells.len()).sum::<usize>(),
Expand Down
13 changes: 12 additions & 1 deletion src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<div");
attrs_html(attrs, out);
Expand Down Expand Up @@ -223,6 +224,7 @@ impl<'a> Renderer<'a> {
&mut self,
attrs: &Attr,
aligns: &[Align],
widths: &[f64],
head: &[TableRow],
rows: &[TableRow],
foot: &[TableRow],
Expand All @@ -231,6 +233,15 @@ impl<'a> Renderer<'a> {
out.push_str("<table");
attrs_html(attrs, out);
out.push_str(">\n");
if !widths.is_empty() {
out.push_str("<colgroup>\n");
for w in widths {
out.push_str("<col style=\"width: ");
out.push_str(&((w * 100.0) as u64).to_string());
out.push_str("%\" />\n");
}
out.push_str("</colgroup>\n");
}
if !head.is_empty() {
out.push_str("<thead>\n");
for row in head {
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/dialect.xhtml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
<h1 id="top" class="main">Title</h1>
<p>A paragraph with <em>em</em>, <strong>strong</strong>, <del>gone</del>, H<sub>2</sub>O, <a href="https://example.com" rel="nofollow">a link</a>, <code class="small">code</code>, <a href="https://example.org">https://example.org</a>, footnote<sup id="fnref-n"><a href="#fn-n" class="footnote-ref" role="doc-noteref">1</a></sup>, and <span class="math inline">a+b</span>.</p>
<table>
<colgroup>
<col style="width: 28%" />
<col style="width: 33%" />
<col style="width: 38%" />
</colgroup>
<thead>
<tr><th align="left">Left</th><th align="right">Right</th><th align="center">Center</th></tr>
</thead>
Expand Down
2 changes: 1 addition & 1 deletion tests/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 11 additions & 0 deletions tests/test_focused.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,14 @@ def test_table_ial_line_attaches():
html = to_xhtml('a|b\n-|-\n1|2\n{: .c}\n')
assert html.startswith('<table class="c">')
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 '<table>\n<colgroup>\n<col style="width: 75%" />\n<col style="width: 25%" />\n</colgroup>\n<thead>' in html
assert "<colgroup>" not in to_xhtml(table, table_widths=False)
html = to_xhtml("| a | b |\n|:---|----:|\n| x | y |\n")
assert '<col style="width: 44%" />\n<col style="width: 55%" />' in html
assert "<colgroup>" not in to_xhtml("| a | b |\n|---|---|\n| x | y |\n")
grid = "+---+----+\n| a | b |\n+===+====+\n| 1 | 2 |\n+---+----+\n"
assert "<colgroup>" not in to_xhtml(grid)
15 changes: 15 additions & 0 deletions tests/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,18 @@ def test_max_link_paren_depth_is_honored():
shallow = "[a](((x)))"
assert "<a" in to_xhtml(shallow)
assert "<a" not in to_xhtml(shallow, max_link_paren_depth=1)

def test_table_widths_node_and_cli():
calls = []

def table(node, default_html):
calls.append(node["widths"])
assert '<col style="width: 75%" />' 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 '<col style="width: 75%" />' 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 "<colgroup>" not in res.stdout