From 4385b37a4a2187bbec97895be11f27ab08c97770 Mon Sep 17 00:00:00 2001 From: Jack Hogan Date: Wed, 28 Oct 2026 15:13:44 +0000 Subject: [PATCH] add equation numbering and cross-references --- README.md | 6 ++--- docs/DIALECT.md | 20 +++++++++++++- py/src/lib.rs | 3 ++- python/mdhtml/export.py | 2 +- python/mdhtml/md.py | 35 +++++++++++++++++++----- python/mdhtml/typst.py | 11 +++++--- src/block.rs | 16 +++++++---- src/export_html.rs | 22 ++++++++++++--- src/resolve.rs | 20 +++++++++----- src/scan.rs | 1 - tests/canonical.rs | 5 ++-- tests/test_export.py | 60 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 167 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2073dae..3da948d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The dialect deviates from CommonMark for three reasons: - GFM: task lists, `~~x~~` strikethrough, angle autolinks, and bare autolinks. Bare URL and email autolinking is on by default. Disable it with `bare_autolinks=False`. Explicit CommonMark angle autolinks remain enabled. - Code: backtick/tilde fenced code blocks, info strings, and Pandoc-style code attributes. - HTML-in-md: elements that `md` can emit, conventional phrasing tags (`u`, `kbd`, `b`, `i`, `ins`, `s`), and custom elements. Other tags render as literal text. `{=html}` raw blocks pass arbitrary HTML through. -- Math: `brackets` is the default mode and recognizes `\(...\)`, `\[...\]`, and `$$...$$`. `dollars` also recognizes `$...$` using Pandoc's non-space/digit dollar rules. `on` preserves `\(...\)` and `\[...\]` delimiters for client-side renderers such as KaTeX. Use `off` to disable math parsing. +- Math: `brackets` is the default mode and recognizes `\(...\)`, `\[...\]`, and `$$...$$`. `dollars` also recognizes `$...$` using Pandoc's non-space/digit dollar rules. `on` preserves `\(...\)` and `\[...\]` delimiters for client-side renderers such as KaTeX. Use `off` to disable math parsing. A display equation with an id, written on the closer as `\]{#eq-energy}` or as an IAL below, is numbered on export and cited with `[@eq-energy]`. - Attributes and inline spans: Pandoc/kramdown-style `{#id .class key="value"}`, block IALs `{: ...}`, span IALs, superscript `^x^`, subscript `~x~`, and highlight `==x==`. - Definition lists: a `Term` line immediately followed by single-line `: definition` or `~ definition` entries. Definitions contain inline content only. Lists are always tight. Adjacent lists merge into one `dl`. - Footnotes: `[^id]` references to defined `[^id]:` definitions with indented continuation blocks. @@ -492,7 +492,7 @@ html = mdhtml2html(md2mdhtml(markdown), number_headings='legal') In `resolve` mode, cross-references become links with resolved text. `[@sec-pay]` becomes `Section 1.`. Groups use text such as "Sections 1. and 1.(a)". Figure and table references use text such as "Figure 1". -`reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds prefix words to the built-in `sec`, `fig`, and `tbl` types. Missing targets, unknown tokens, and unknown types that need a prefix raise errors. The Word-only `page` and `rel` variants display the full number. +`reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds prefix words to the built-in `sec`, `fig`, `tbl`, and `eq` types. Missing targets, unknown tokens, and unknown types that need a prefix raise errors. The Word-only `page` and `rel` variants display the full number. Two other modes support previews and drafts: @@ -515,7 +515,7 @@ Heading numbers appear in `` elements. Reference te Scheme level 0 is the h1 document title. Its empty `lvlText` suppresses its number. A new title restarts every lower counter. `%2` is the h2 counter. A file containing multiple documents, each starting with h1, therefore numbers each document from 1. Custom dictionaries use the same layout with the title entry first. Cite a title with `{ref=text}` because it has no number. -Figures and tables have independent counters whenever references resolve. A caption or id produces a label such as `Figure 1: ` in the `figcaption` or `caption`. +Figures, tables and equations have independent counters whenever references resolve. A caption or id produces a label such as `Figure 1: ` in the `figcaption` or `caption`. A display equation with an id gets `\tag{1}` appended to its TeX, which KaTeX renders as the equation number. #### Code highlighting diff --git a/docs/DIALECT.md b/docs/DIALECT.md index 9f1c5f7..76cb39f 100644 --- a/docs/DIALECT.md +++ b/docs/DIALECT.md @@ -328,6 +328,24 @@ The text is the source math notation, not MathML. A callback may instead return The carriers are the whole contract: each converter decides how to render the notation. The HTML exporter leaves them in place for client-side rendering, and `math_js(fn=None, **opts)` returns the matching renderer as a string - a guarded per-node KaTeX pass over the carriers (`fn=` names the function instead of invoking it immediately; keyword options merge into the `katex.render` call). +A display equation with an id is numbered. The id is written on the closer or as an IAL on the line below; both attach to the carrier: + +```markdown +\[ +E=mc^2 +\]{#eq-energy} + +See [@eq-energy]. +``` + +```html +
E=mc^2

See .

+``` + +Exporters number id-bearing display equations in document order, as they number figures and tables, and `[@eq-energy]` resolves to "Equation 1". The HTML and GFM exporters append `\tag{1}` to the carrier's TeX, which KaTeX and MathJax render as the equation number; Typst numbers labelled equations natively. Display math without an id is left as authored and is not a reference target. + +Only a math block is an equation: `\[E=mc^2\]{#eq-energy}` on one line is display-styled math inside a paragraph and is not numbered. An id-bearing equation must not carry its own `\tag`; the exporter adds one, and a second produces a rendering error. + ## Footnotes `md`: @@ -381,7 +399,7 @@ The `md` `{ref=...}` key is consumed when lowering a reference. On any other ele The parser does not resolve numbers or require targets to exist. Converters report an unresolved target as a conversion error when their output format supports live references. -Reference targets are the id-bearing headings, paragraphs, figures, tables, spans, and definition terms. Headings resolve to a heading number ("Section 1"), figures and tables to a caption number ("Table 2"), and paragraphs only through `{ref=text}`. Spans and definition terms resolve to their own text with no prefix word, so `the [@def-term] period` reads as running prose citing the defined term, and a rendering variant that needs a number (`leaf`, `rel`) is an error for them. Converters with a link mechanism link the cited text back to its definition site; `md2gfm` renders the text alone. +Reference targets are the id-bearing headings, paragraphs, figures, tables, display equations, spans, and definition terms. Headings resolve to a heading number ("Section 1"), figures, tables and equations to a caption number ("Table 2", "Equation 3"), and paragraphs only through `{ref=text}`. Spans and definition terms resolve to their own text with no prefix word, so `the [@def-term] period` reads as running prose citing the defined term, and a rendering variant that needs a number (`leaf`, `rel`) is an error for them. Converters with a link mechanism link the cited text back to its definition site; `md2gfm` renders the text alone. The shipped exporters lower references from one shared vocabulary (`mdhtml.export`) at three levels of liveness: `mdhtml2docx` bakes REF fields Word keeps live, `mdhtml2html` bakes links with computed text, and `md2gfm` bakes plain text. Prefix words come from `REFTYPES` (`sec`, `fig`, `tbl`; extended per call with `reftypes=`) and heading numbering from `SCHEMES` (`'legal'`, `'decimal'`, or a `{lvlText: numFmt}` dict). Automatic numbering numbers headings exactly when some reference needs a heading number. For HTML, GFM, and Typst, `number_headings=None` inherits the input's metadata setting, falling back to automatic numbering; `False` disables heading numbering, and numeric references to unnumbered headings are errors. Scheme level 0 is the h1 document title, with an empty lvlText: it shows no number, and bumping it restarts every level below, so `%2` is the h2 counter and each document in a file that opens with its own h1 numbers from 1; a title is cited with `{ref=text}`, never by number. diff --git a/py/src/lib.rs b/py/src/lib.rs index ed71a11..dbefe47 100644 --- a/py/src/lib.rs +++ b/py/src/lib.rs @@ -168,7 +168,8 @@ fn anchors(py: Python<'_>, markdown: &str, math: &str, templates: Option Option<&'static str> { resolve::target_kind(name) } +#[pyo3(signature = (name, classes = ""))] +fn target_kind(name: &str, classes: &str) -> Option<&'static str> { resolve::target_kind(name, classes) } /// Byte range of the trailing attribute group of `line`, or None: the group /// `strip_trailing_attr` would consume, as the dialect's own grammar judges it. diff --git a/python/mdhtml/export.py b/python/mdhtml/export.py index 7679d66..177fdc6 100644 --- a/python/mdhtml/export.py +++ b/python/mdhtml/export.py @@ -96,7 +96,7 @@ def go(text, lang): def mdhtml2html(src, dest=None, reftypes: dict | None = None, number_headings=None, hl: str | None = "spans", auto_ids: bool = True, toc: bool = False, refs: str = "resolve", id_prefix: str = "", fn_salt: str = "", hl_lang=None, code_wrap=None, gh_ids: bool = False) -> Html: """Lower MDHTML (a string or DocumentFragment; never mutated) to finished HTML: cross-references - baked as links, headings and captions numbered, `{=html}` raw data spliced, `colwidths` lowered, + baked as links, headings, captions and equations numbered, `{=html}` raw data spliced, `colwidths` lowered, and code highlighted. A `div[data-panel]` with open/closed disclosure lowers to `
`, its title `header` becoming `summary` (id kept, excluded from TOC and numbering). Fixed panels keep their `div` and `header`; real body headings remain headings. diff --git a/python/mdhtml/md.py b/python/mdhtml/md.py index c16c5a3..d9c5bec 100644 --- a/python/mdhtml/md.py +++ b/python/mdhtml/md.py @@ -1,5 +1,5 @@ """Markdown exporter: a source-preserving lowering of mdhtml-specific constructs to portable -GFM-plus-footnotes. Cross-references bake to plain text, headings and captions are numbered, +GFM-plus-footnotes. Cross-references bake to plain text, headings, captions and equations are numbered, attribute machinery is stripped, and `{=md}` raw data is spliced; all other source text, including everything a competent Markdown renderer already handles, passes through byte-identical. Inline constructs are lowered everywhere; block constructs wherever their lines carry no container @@ -168,13 +168,15 @@ def _index(self, spans, nodes): if i := b.get("id"): res.register(i, "block", b["text"]) self.caps, counts = {}, {} for b in spans: - if b["type"] not in ("figure", "table"): continue - cap = b.get("caption") if b["type"] == "table" else b.get("text") - if not cap and "id" not in b: continue - label = res.reftypes["tbl" if b["type"] == "table" else "fig"][0] + if b["type"] == "math_block": cap, i, typ = b.get("text"), b["attrs"]["id"], "eq" + elif b["type"] in ("figure", "table"): + cap, i, typ = b.get("caption" if b["type"] == "table" else "text"), b.get("id"), "tbl" if b["type"] == "table" else "fig" + else: continue + if not i and (typ == "eq" or not cap): continue # equations number only through an id + label = res.reftypes[typ][0] n = counts[label] = counts.get(label, 0) + 1 self.caps[id(b)] = (label, n) - if i := b.get("id"): + if i: res.register(i, 'caption', cap or '') res.set_capnum(i, label, n) self.xrefs = [] @@ -275,6 +277,25 @@ def _block(self, b): p = self._chars(s, e)[1] self.block.append((p, p, f"\n{label} {n}{cap}\n")) elif t == "table": self._table(b, s, e) + elif t == "math_block": self._equation(b, s, e) + + def _equation(self, b, s, e): + "Strip an equation's attribute list (on the closer or an IAL at either edge) and give a numbered one its own `\tag{n}` line; a quoted block is left as written." + num = self.caps.get(id(b)) + o, c = s, e - 1 + while c > o and _is_ial(self.lines[o]): o += 1 + while c > o and _is_ial(self.lines[c]): c -= 1 + if self.lines[o].strip() not in (r"\[", "$$"): + if num: self.warnings.append(f"equation at line {s + 1} inside a container was not rewritten") + return + closer = self.lines[c].encode() + sp = _trailing_attr_span(self.lines[c]) + body = closer[:sp[0]].rstrip() if sp else closer.rstrip() + if c == o or body.strip() not in (rb"\]", b"$$"): return # unclosed block: nothing to strip or number + self._strip_edge_ials(s, e) + if sp: self.block.append((self.starts[c] + len(body), self.starts[c] + sp[1], "")) + indent = body[:len(body) - len(body.lstrip())].decode() + if num: self.block.append((self.starts[c], self.starts[c], f"{indent}\\tag{{{num[1]}}}\n")) def _table(self, b, s, e): s, e = self._strip_edge_ials(s, e) @@ -292,7 +313,7 @@ def _table(self, b, s, e): def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, math: str = "brackets", implicit_figures: bool = False, templates=None, tmpl=None, raw: tuple = ("md",), imgdir=None, link=None) -> Md: """Lower Markdown to portable GFM-plus-footnotes by rewriting mdhtml-specific constructs in - place: cross-references become plain text, headings and captions are numbered, attribute + place: cross-references become plain text, headings, captions and equations are numbered, attribute lists and definitions are stripped, and raw data in the formats named by `raw` is spliced (all other formats drop; `('md', 'html')` suits targets that render inline HTML, like GFM). With `imgdir`, each base64 data-URI image is written to a content-hashed file in that diff --git a/python/mdhtml/typst.py b/python/mdhtml/typst.py index d7c7972..77a302f 100644 --- a/python/mdhtml/typst.py +++ b/python/mdhtml/typst.py @@ -69,13 +69,14 @@ def __init__(self, reftypes, number_headings, tmpl, table_styles=None): self.number_headings, self.tmpl = number_headings, tmpl self.table_styles = {k.lower(): v for k, v in (table_styles or {}).items()} self.warnings, self.headids, self.fnotes, self.fnotes_done = [], set(), {}, set() - self.has_math = self.need_nums = self.need_page_nums = False + self.has_math = self.need_nums = self.need_page_nums = self.need_eq_nums = False def run(self, root): for e in _walk_all(root): if not (i := e.attrs.get("id")): continue - kind = target_kind(e.name) + kind = target_kind(e.name, e.attrs.get("class", "")) if e.name in _HEADS: self.headids.add(i) + if e.name == "div" and kind == "caption": self.need_eq_nums = True self.register(i, kind, _text(e)) self._harvest_footnotes(root) return self._blocks(root) @@ -246,7 +247,10 @@ def _inline_el(self, el): def _math(self, el, display): self.has_math = True - return f"#{'mitex' if display else 'mi'}({_rawarg(el.to_text())})" + math = f"#{'mitex' if display else 'mi'}({_rawarg(el.to_text())})" + if not display or not self.need_eq_nums: return math + # Equations number only through an id: an unlabelled one opts out of the document's numbering rule. + return math + self._label(el) if el.attrs.get("id") else f"#[\n#set math.equation(numbering: none)\n{math}\n]" def _footnote(self, el): a = next((c for c in el.element_children if c.name == "a"), None) @@ -333,6 +337,7 @@ def mdhtml2typst(src, dest=None, reftypes: dict | None = None, number_headings=N body = ex.run(mdhtml2dom(src)) parts = [prelude.rstrip()] if prelude else [] if ex.has_math: parts.append(f'#import "{MITEX}": mi, mitex') + if ex.need_eq_nums: parts.append('#set math.equation(numbering: "(1)")') if number_headings is False: parts.append('#set heading(numbering: none)') elif number_headings is not None or ex.need_nums: parts.append(_numbering_code(number_headings or "decimal")) if ex.need_page_nums: parts.append('#set page(numbering: "1")') diff --git a/src/block.rs b/src/block.rs index 9a18f4d..3a31598 100644 --- a/src/block.rs +++ b/src/block.rs @@ -753,7 +753,7 @@ enum BuildKind { DefinitionList { attrs: Attr, items: Vec }, Div { attrs: Attr, fence_len: usize, close_line: Option }, FencedCode { ch: char, len: usize, fence_indent: usize, info: String, text: String, closed: bool }, - Math { close: &'static str, tex: String, closed: bool }, + Math { close: &'static str, attrs: Attr, tex: String, closed: bool }, Paragraph { lines: Vec }, Heading { level: u8, attrs: Attr, text: String }, ThematicBreak { attrs: Attr }, @@ -1421,15 +1421,21 @@ impl<'a> ContainerBuilder<'a> { if !matches!(self.options.math, MathMode::Brackets | MathMode::Dollars) { return false; } let t = line.trim(); let close = if t == "\\[" { "\\]" } else if t == "$$" { "$$" } else { return false; }; - self.open_node(BuildKind::Math { close, tex: String::new(), closed: false }); + self.open_node(BuildKind::Math { close, attrs: Attr::default(), tex: String::new(), closed: false }); true } fn feed_open_math(&mut self, line: &str) -> bool { let Some(idx) = self.open_math_idx() else { return false }; let BuildKind::Math { close, .. } = self.nodes[idx].kind else { return false }; - if line.trim() == close { - if let BuildKind::Math { closed, .. } = &mut self.nodes[idx].kind { *closed = true; } + // The closer may carry the block's attribute list, as a fence line does: `\]{#eq-1}`. + let (text, attrs) = strip_trailing_attr(line.trim()); + if text == close { + if let Some((open, aend)) = trailing_attr_span(line) { self.note_syntax(self.cur_offset + open, self.cur_offset + aend, SyntaxScope::Attr); } + if let BuildKind::Math { closed, attrs: a, .. } = &mut self.nodes[idx].kind { + *closed = true; + a.merge(&attrs); + } self.leaf_open = false; return true; } @@ -1636,7 +1642,7 @@ impl<'a> ContainerBuilder<'a> { } else if let Some(lang) = script_fence_lang(trimmed) { DraftBlock::Script { lang: lang.to_string(), text: text.clone() } } else { let (info, lang, attrs, _) = parse_fence_info(info); DraftBlock::CodeBlock { attrs, info, lang, text: text.clone() } } } - BuildKind::Math { tex, .. } => DraftBlock::Math { attrs: Attr::default(), display: true, tex: tex.trim_end().to_string() }, + BuildKind::Math { tex, attrs, .. } => DraftBlock::Math { attrs: attrs.clone(), display: true, tex: tex.trim_end().to_string() }, BuildKind::Paragraph { lines, .. } => return self.finish_paragraph(lines, self.nodes[idx].start_line, parser), BuildKind::Heading { level, attrs, text } => DraftBlock::Heading { level: *level, attrs: attrs.clone(), text: text.clone() }, BuildKind::ThematicBreak { attrs } => DraftBlock::ThematicBreak { attrs: attrs.clone() }, diff --git a/src/export_html.rs b/src/export_html.rs index 65adeea..d75bf7c 100644 --- a/src/export_html.rs +++ b/src/export_html.rs @@ -113,7 +113,7 @@ impl Exporter { for &e in &els { let Some(id) = self.dom.attr(e, "id").map(str::to_string) else { continue }; let name = self.dom.tag(e).unwrap(); - let kind = target_kind(name); + let kind = target_kind(name, self.dom.attr(e, "class").unwrap_or("")); let text = norm_text(&self.dom, e); self.res.register(&id, kind, Some(&text)); } @@ -141,7 +141,7 @@ impl Exporter { } } self.number_headings(&refs, opts)?; - let figs: Vec = els.iter().copied().filter(|&e| matches!(self.dom.tag(e), Some("figure") | Some("table"))).collect(); + let figs: Vec = els.iter().copied().filter(|&e| matches!(self.dom.tag(e), Some("figure") | Some("table")) || self.equation(e)).collect(); self.number_captions(&figs); for &g in &groups { self.lower_group(g, opts)?; } for &a in &singles { @@ -248,11 +248,27 @@ impl Exporter { Ok(()) } + /// Is `el` a display-math carrier, the `div` that numbers as an equation? + fn equation(&self, el: NodeId) -> bool { self.dom.is_tag(el, "div", None) && target_kind("div", self.dom.attr(el, "class").unwrap_or("")).is_some() } + /// Number figures and tables that have a caption or an id, baking - /// `Label N` into the caption. + /// `Label N` into the caption, and equations that have an id, baking + /// `\tag{N}` into the TeX. fn number_captions(&mut self, els: &[NodeId]) { let mut counts: HashMap = HashMap::new(); for &el in els { + if self.equation(el) { + // An equation numbers only through its id; `\tag` carries the number into its TeX. + let Some(id) = self.dom.attr(el, "id").map(str::to_string) else { continue }; + let label = self.res.reftypes["eq"].0.clone(); + let n = *counts.entry(label.clone()).and_modify(|c| *c += 1).or_insert(1); + self.res.set_capnum(&id, &label, n); + let tex = format!("{}\n\\tag{{{n}}}", self.dom.to_text(el).trim_end()); + self.dom.clear_children(el); + let t = self.dom.create_text(&tex); + self.dom.append_child(el, t).unwrap(); + continue; + } let fig = self.dom.tag(el) == Some("figure"); let capname = if fig { "figcaption" } else { "caption" }; let capel = self.dom.element_children(el).into_iter().find(|&c| self.dom.tag(c) == Some(capname)); diff --git a/src/resolve.rs b/src/resolve.rs index 16fb04b..39eaf0a 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -26,7 +26,7 @@ pub fn schemes() -> Vec<(&'static str, Vec<(String, String)>)> { /// Built-in reference-type prefix words: `(singular, plural)` by id prefix. pub fn reftypes() -> Vec<(&'static str, (&'static str, &'static str))> { - vec![("sec", ("Section", "Sections")), ("fig", ("Figure", "Figures")), ("tbl", ("Table", "Tables"))] + vec![("sec", ("Section", "Sections")), ("fig", ("Figure", "Figures")), ("tbl", ("Table", "Tables")), ("eq", ("Equation", "Equations"))] } const VARIANTS: [&str; 4] = ["page", "text", "leaf", "rel"]; @@ -202,7 +202,9 @@ impl Resolver { /// Error for a reference to an unknown target id. pub fn check(&self, tgt: &str) -> Result<(), String> { if self.kinds.contains_key(tgt) { Ok(()) } else { - Err(format!("cross-reference target #{tgt} not found (targets are headings, paragraphs, figures, tables, spans, and definition terms with ids)")) + Err(format!( + "cross-reference target #{tgt} not found (targets are headings, paragraphs, figures, tables, equations, spans, and definition terms with ids)" + )) } } @@ -235,12 +237,15 @@ impl Resolver { } /// The Resolver kind an id-bearing element registers under, from its element -/// name: `caption` targets render their caption number, `block` targets a -/// heading number, and `text` targets (spans, definition terms) their text. -pub fn target_kind(name: &str) -> Option<&'static str> { +/// name and class attribute: `caption` targets render their caption number, +/// `block` targets a heading number, and `text` targets (spans, definition +/// terms) their text. A display-math `div` is a `caption` target: it numbers +/// as an equation. +pub fn target_kind(name: &str, classes: &str) -> Option<&'static str> { + let has = |c: &str| classes.split_whitespace().any(|x| x == c); match name { - "figure" | "table" => Some("caption"), "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Some("block"), - "span" | "dt" | "header" | "summary" => Some("text"), _ => None + "figure" | "table" => Some("caption"), "div" if has("math") && has("display") => Some("caption"), + "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Some("block"), "span" | "dt" | "header" | "summary" => Some("text"), _ => None } } @@ -277,6 +282,7 @@ fn anchor_block(b: &Block, out: &mut Vec<(String, &'static str, String)>) { anchor_inlines(caption, out); for row in head.iter().chain(rows).chain(foot) { for cell in &row.cells { anchor_inlines(&cell.content, out); } } } + Block::Math { attrs, display: true, tex } => push_anchor(attrs, "caption", tex.clone(), out), Block::DefinitionList { items, .. } => { for item in items { for t in &item.terms { diff --git a/src/scan.rs b/src/scan.rs index 8bee509..c22ba16 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -65,5 +65,4 @@ mod tests { let src = r#"${make({"x": "}"})} rest"#; assert_eq!(balanced_end(src, 2, "}", Balance::new('{', '}')), Some(18)); } - } diff --git a/tests/canonical.rs b/tests/canonical.rs index 9aad772..e19c13a 100644 --- a/tests/canonical.rs +++ b/tests/canonical.rs @@ -1,5 +1,5 @@ -use mdhtml::{Options, parse, render, render_md}; use mdhtml::export_html::{HlMode, HtmlExportOptions, export_html}; +use mdhtml::{Options, parse, render, render_md}; #[test] fn panel_titles_and_nested_panels_roundtrip() { @@ -58,7 +58,7 @@ Inside a div. \[ y = 2 -\] +\]{#eq-two} ![Plot](plot.png){#fig-plot} {: data-kind="chart"} @@ -86,6 +86,7 @@ Text with a note[^n]. let canonical = render_md(&document); let reparsed = parse(&canonical, &options); assert_eq!(render(&reparsed), render(&document), "canonical Markdown:\n{canonical}"); + assert!(render(&document).contains(r#"
"#), "the closer's attribute list attaches to the math block"); assert_eq!(reparsed.meta, document.meta); let code = render(&parse("```python\nx = 1\n```\n\n```md\n# Title\n```", &options)); let (html, warnings) = export_html(&code, &HtmlExportOptions { hl: Some(HlMode::Spans), ..Default::default() }).unwrap(); diff --git a/tests/test_export.py b/tests/test_export.py index bccefcb..091be02 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -443,3 +443,63 @@ def test_lenient_refs_resolve_what_they_can(): exhibit = md2mdhtml('## Proof {#exh-proof}\n\nSee [@exh-proof].') with pytest.raises(ValueError): mdhtml2html(exhibit) assert 'Exhibit 1' in mdhtml2html(exhibit, reftypes=dict(exh=('Exhibit', 'Exhibits'))) + + +EQUATIONS = r'''## Model {#sec-model} + +\[ +E = mc^2 +\]{#eq-energy} + +\[ +F = ma +\] +{: #eq-force} + +\[ +x = y +\] + +::: callout-note +\[ +a = b +\]{#eq-note} +::: + +> \[ +> c = d +> \]{#eq-quoted} + +See [@eq-energy], [-@eq-force], [@eq-energy; @eq-force], [@eq-note], and [@eq-quoted]. +''' + + +def test_equation_numbering(): + doc = md2mdhtml(EQUATIONS) + assert not doc.warnings + assert '
E = mc^2
' in doc # the closer carries the attribute list + html = mdhtml2html(doc, number_headings=False) + assert 'E = mc^2\n\\tag{1}
' in html and 'F = ma\n\\tag{2}' in html and 'x = y' in html + assert 'a = b\n\\tag{3}' in html and 'c = d\n\\tag{4}' in html + assert 'Equation 1, 2' in html + assert 'Equation 1 and Equation 2' in html + typst = mdhtml2typst(doc, number_headings=False) + assert '#set math.equation(numbering: "(1)")' in typst and '#mitex(`E = mc^2`) ' in typst + assert '#set math.equation(numbering: none)\n#mitex(`x = y`)' in typst and '@eq-energy' in typst and '#mitex(`a = b`) ' in typst + gfm = md2gfm(EQUATIONS, number_headings=False) + assert 'E = mc^2\n\\tag{1}\n\\]\n' in gfm and 'F = ma\n\\tag{2}\n\\]\n' in gfm and 'x = y\n\\]\n' in gfm + assert '> a = b\n> \\tag{3}\n> \\]\n' in gfm # a callout's lines carry no prefix, so they rewrite + assert '> \\]{#eq-quoted}' in gfm and 'tag{4}' not in gfm # a quoted block is left as written + assert any(w.startswith('equation at line') for w in gfm.warnings) + assert '{#eq-energy}' not in gfm and '{: #eq-force}' not in gfm and '{#eq-note}' not in gfm + assert 'See Equation 1, 2, Equation 1 and Equation 2, Equation 3, and Equation 4.' in gfm + commented = '\\[\nE = mc^2 % energy\n\\]{#eq-c}\n' + assert 'E = mc^2 % energy\n\\tag{1}' in mdhtml2html(md2mdhtml(commented)) # the tag's own line survives a TeX comment + assert md2gfm(commented) == '\\[\nE = mc^2 % energy\n\\tag{1}\n\\]\n' + assert md2gfm('- item\n\n \\[\n z\n \\]{#eq-list}\n') == '- item\n\n \\[\n z\n \\tag{1}\n \\]\n' # indented lines keep their indent + assert md2gfm('\\[\n\\text{a=b}\n') == '\\[\n\\text{a=b}\n' # unclosed: the body is not an attribute list + with pytest.raises(ValueError, match='equations'): mdhtml2html(md2mdhtml('\\[\nx\n\\]\n\nSee [@eq-x].')) + assert 'math.equation' not in mdhtml2typst(md2mdhtml('\\[\nx = y\n\\]\n')) # no id anywhere: no numbering rule + above = '{: #eq-above}\n\\[\nq = r\n\\]\n\nSee [@eq-above].\n' + assert md2gfm(above) == '\\[\nq = r\n\\tag{1}\n\\]\n\nSee Equation 1.\n' # an IAL above the opener + assert md2gfm('\\[\n{: a=b}\n') == '\\[\n{: a=b}\n' # unclosed: an IAL-shaped last line is TeX