Skip to content
Closed
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -492,7 +492,7 @@ html = mdhtml2html(md2mdhtml(markdown), number_headings='legal')

In `resolve` mode, cross-references become links with resolved text. `[@sec-pay]` becomes `<a href="#sec-pay">Section 1.</a>`. 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:

Expand All @@ -515,7 +515,7 @@ Heading numbers appear in `<span class="heading-number">` 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 `<span class="caption-label">Figure 1</span>: ` in the `figcaption` or `caption`.
Figures, tables and equations have independent counters whenever references resolve. A caption or id produces a label such as `<span class="caption-label">Figure 1</span>: ` 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

Expand Down
20 changes: 19 additions & 1 deletion docs/DIALECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div id="eq-energy" class="math display">E=mc^2</div><p>See <a href="#eq-energy" data-ref=""></a>.</p>
```

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`:
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ fn anchors(py: Python<'_>, markdown: &str, math: &str, templates: Option<Vec<Tem
}

#[pyfunction]
fn target_kind(name: &str) -> 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.
Expand Down
2 changes: 1 addition & 1 deletion python/mdhtml/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>`,
its title `header` becoming `summary` (id kept, excluded from TOC and numbering).
Fixed panels keep their `div` and `header`; real body headings remain headings.
Expand Down
35 changes: 28 additions & 7 deletions python/mdhtml/md.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions python/mdhtml/typst.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")')
Expand Down
16 changes: 11 additions & 5 deletions src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ enum BuildKind {
DefinitionList { attrs: Attr, items: Vec<DraftDefinitionItem> },
Div { attrs: Attr, fence_len: usize, close_line: Option<usize> },
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<String> },
Heading { level: u8, attrs: Attr, text: String },
ThematicBreak { attrs: Attr },
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() },
Expand Down
Loading
Loading