Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ html = mdhtml2html(md2mdhtml(markdown), number_headings='legal')
The result is still a body fragment (a str subclass carrying a `warnings` list; pass `dest=` to also write a file). `mdhtml2html` accepts an MDHTML string or a fast5ever node, never mutates its input, and applies:

- Cross-references become real links with baked text: `[@sec-pay]` renders as `<a href="#sec-pay">Section 1.</a>`, groups join as "Sections 1. and 1.(a)", and figure and table targets get "Figure 1"-style text. `reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds prefix words beyond the built-in `sec`, `fig`, and `tbl`. A missing target, an unknown token, or an unknown type needing a prefix raises. The Word-only `page` and `rel` variants render as the full number. `refs='ids'` is the second mode, for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (`<a href="#sec-pay" class="xref">sec-pay</a>`, author text kept as a prefix, variants ignored), with no registry, numbering, or failure modes; captions render as authored, since without a registry the numbers would restart per fragment. `refs='lenient'` is the third, for drafts: everything resolves and numbers as in `resolve` mode, except that each reference which cannot resolve bakes as its `ids` link and is reported in `warnings` instead of raising. `id_prefix='md-'` namespaces the output against the ids of a host page: every element id is prefixed (the original kept in `data-id`, e.g. for CSS `attr()` markers), along with ref hrefs and any link to an in-fragment id; links to outside ids are untouched. `fn_salt` adds a further prefix to footnote ids only (`fn-*`/`fnref-*`), keeping footnote pairs distinct across fragments that share one `id_prefix`.
- Headings are numbered when `number_headings` is given ('legal', 'decimal', or a `{lvlText: numFmt}` dict as in mdhtml2docx), or automatically with 'decimal' when some reference needs a heading number. Numbers bake in as `<span class="heading-number">`, and full-context reference text ("3.(c)(iii)") is computed Word-style from the scheme.
- Headings are numbered when `number_headings` is given ('legal', 'decimal', or a `{lvlText: numFmt}` dict as in mdhtml2docx), or automatically with 'decimal' when some reference needs a heading number. Numbers bake in as `<span class="heading-number">`, and full-context reference text ("3.(c)(iii)") is computed Word-style from the scheme. Scheme level 0 is the h1 document title: its empty lvlText shows no number, and bumping it restarts every level below (Word's own rule), so `%2` is the h2 counter and a file holding several documents, each opening with an h1, numbers each of them from 1. A custom dict has the same shape, title entry first. A title cannot be cited by number; cite it with `{ref=text}`.
- Figures and tables number independently whenever refs resolve: a caption or an id earns a `<span class="caption-label">Figure 1</span>: ` in the `figcaption` or `caption`.
- `{=html}` raw data is decoded and spliced in place; raw data for other formats is removed. Malformed payloads are dropped with a warning.
- A `colwidths` attribute lowers to a `<colgroup>`; `fr` values share the width remaining after fixed lengths.
Expand Down
2 changes: 1 addition & 1 deletion docs/DIALECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ The parser does not resolve numbers or require targets to exist. Converters repo

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.

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). `number_headings=None` means automatic: headings are numbered exactly when some reference needs a heading number.
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). `number_headings=None` means automatic: headings are numbered exactly when some reference needs a heading number. 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.

`mdhtml2html` also offers `refs='ids'` for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (class `xref`), with no registry, numbering, or failure modes - and captions render as authored, since per-fragment numbers would lie. `id_prefix` namespaces the fragment's ids against a host page (the authored id kept in `data-id`), and `fn_salt` adds a further prefix to the `fn-*`/`fnref-*` footnote namespace only, keeping footnote pairs distinct across fragments that share one `id_prefix`.

Expand Down
2 changes: 1 addition & 1 deletion python/mdhtml/md.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def _index(self, spans, nodes):
if self.number_headings or needed:
nums = HeadingNums(self.number_headings or "decimal")
for b in self.heads:
if (d := nums.bump(b["level"] - 1)) is None: continue
if not (d := nums.bump(b["level"] - 1)): continue # None beyond the scheme, '' at the title level
self.headnum[id(b)] = d
if i := b.get("id"): res.set_headnum(i, d, nums.full(b['level'] - 1))

Expand Down
4 changes: 2 additions & 2 deletions python/mdhtml/typst.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ def _numbering_code(scheme):
hn = HeadingNums(scheme)
out = ["#let mdhtml-numbering(..ns) = {", " let n = ns.pos()"]
for i, (lvl, _) in enumerate(hn.scheme):
full = lvl if i == 0 or "%1" in lvl else "".join(t for t, _ in hn.scheme[:i + 1])
full = lvl if "%2" in lvl else "".join(t for t, _ in hn.scheme[:i + 1])
parts = re.split(r"%(\d)", full)
expr = " + ".join(f'numbering("{_SYM[hn.scheme[int(p) - 1][1]]}", n.at({int(p) - 1}))' if j % 2 else f'"{p}"'
for j, p in enumerate(parts) if j % 2 or p)
for j, p in enumerate(parts) if j % 2 or p) or '""' # the title level shows nothing
out.append(f" {'if' if i == 0 else 'else if'} n.len() == {i + 1} {{ {expr} }}")
return "\n".join(out) + "\n}\n#set heading(numbering: mdhtml-numbering)"

Expand Down
1 change: 1 addition & 0 deletions src/export_html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ impl Exporter {
for &el in &self.heads.clone() {
let lvl = ename(&self.dom, el).unwrap()[1..].parse::<usize>().unwrap() - 1;
let Some(d) = nums.bump(lvl) else { continue };
if d.is_empty() { continue } // the title level: nothing to show, and no number to cite
let first = self.dom.children(el).first().copied();
let space = self.dom.create_text(" ");
self.dom.insert_before(el, space, first).unwrap();
Expand Down
18 changes: 10 additions & 8 deletions src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ use base64::Engine;
use crate::ast::{Attr, Block, Document, Inline};

/// The built-in `{lvlText: numFmt}` heading numbering schemes, in level order.
/// Level 0 is the h1 document title: its empty lvlText shows no number, and
/// bumping it resets every level below (Word's own rule), so `%2` is the h2
/// counter and a file holding several documents, each opening with an h1,
/// numbers each of them from 1.
pub fn schemes() -> Vec<(&'static str, Vec<(String, String)>)> {
let decimal = (0..6)
.map(|i| {
let lvl = (1..=i + 1).map(|j| format!("%{j}")).collect::<Vec<_>>().join(".");
(format!("{lvl}."), "decimal".to_string()) // trailing dot ("1.", "1.1."): the caption form every corpus contract uses
})
let decimal = (0..7)
.map(|i| ((2..=i + 1).map(|j| format!("%{j}.")).collect::<String>(), "decimal".to_string())) // "", "%2.", "%2.%3.": the trailing-dot caption form every corpus contract uses
.collect();
let legal = [("%1.", "decimal"), ("(%2)", "lowerLetter"), ("(%3)", "lowerRoman"), ("(%4)", "upperLetter"), ("(%5)", "upperRoman"), ("(%6)", "decimal")]
let legal = [("", "decimal"), ("%2.", "decimal"), ("(%3)", "lowerLetter"), ("(%4)", "lowerRoman"), ("(%5)", "upperLetter"), ("(%6)", "upperRoman"), ("(%7)", "decimal")]
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect();
Expand Down Expand Up @@ -170,9 +171,10 @@ impl HeadingNums {
}

/// Word-style full context: ancestor displays concatenated, unless
/// `lvl`'s own lvlText already includes them.
/// `lvl`'s own lvlText already includes them (it carries `%2`, the top
/// visible counter).
pub fn full(&self, lvl: usize) -> String {
if lvl == 0 || self.scheme[lvl].0.contains("%1") { return self.display(lvl); }
if self.scheme[lvl].0.contains("%2") { return self.display(lvl); }
(0..=lvl).map(|i| self.display(i)).collect()
}
}
Expand Down
50 changes: 34 additions & 16 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

from mdhtml import TemplateDelimiter, dialect_css, math_js, mdhtml2dom, mdhtml2html, md2gfm, md2mdhtml
from mdhtml.mustache import MUSTACHE, mustache_pill
from mdhtml.export import SCHEMES

REFS_MD = """# Payment {#sec-pay}
REFS_MD = """# Agreement

## Late fees {#sec-late}
## Payment {#sec-pay}

### Late fees {#sec-late}

See [@sec-pay], [-@sec-late], [Clause @sec-late], [@sec-pay; @sec-late], [@sec-late]{ref=leaf},
[-@sec-late]{ref=text}, and page [-@sec-late]{ref=page}.
Expand All @@ -26,17 +29,17 @@ def test_refs_and_heading_numbering():
assert 'page <a href="#sec-late">1.(a)</a>' in h # page degrades to full
assert 'data-ref' not in h
assert h.warnings == []
d = mdhtml2html(md2mdhtml('# One {#sec-a}\n\n## Two {#sec-b}\n\nSee [@sec-b].'), number_headings='decimal')
d = mdhtml2html(md2mdhtml('## One {#sec-a}\n\n### Two {#sec-b}\n\nSee [@sec-b].'), number_headings='decimal')
assert '<span class="heading-number">1.1.</span> Two' in d
assert '<a href="#sec-b">Section 1.1</a>' in d


def test_ref_errors():
with pytest.raises(ValueError, match='not found'): mdhtml2html(md2mdhtml('See [@sec-x].'))
auto = mdhtml2html(md2mdhtml('# A {#sec-a}\n\nSee [@sec-a].')) # refs trigger auto decimal numbering
auto = mdhtml2html(md2mdhtml('## A {#sec-a}\n\nSee [@sec-a].')) # refs trigger auto decimal numbering
assert '<span class="heading-number">1.</span> A' in auto and '<a href="#sec-a">Section 1</a>' in auto
assert 'heading-number' not in mdhtml2html(md2mdhtml('# A {#sec-a}\n\nText.')) # no numeric ref: no numbering
md = '# A {#exh-a}\n\nSee [@exh-a].'
md = '## A {#exh-a}\n\nSee [@exh-a].'
with pytest.raises(ValueError, match='reftypes'): mdhtml2html(md2mdhtml(md), number_headings='legal')
h = mdhtml2html(md2mdhtml(md), number_headings='legal', reftypes=dict(exh=('Exhibit', 'Exhibits')))
assert '<a href="#exh-a">Exhibit 1</a>' in h
Expand All @@ -45,8 +48,23 @@ def test_ref_errors():



def test_title_is_numbering_boundary():
"h1 is the unnumbered title and restarts the count: every document in a file that opens with an h1 numbers from 1"
src = '# T\n\n## A {#sec-a}\n\n### B {#sec-b}\n\n# T2\n\n## C {#sec-c}\n\n### D {#sec-d}\n\nSee [@sec-b] and [@sec-d].'
h = mdhtml2html(md2mdhtml(src), number_headings='decimal')
assert 'heading-number' not in h.split('<h2')[0] # no number, and no empty span, on the title
assert h.count('<span class="heading-number">1.</span>') == 2 # A and C both number 1.
assert h.count('<span class="heading-number">1.1.</span>') == 2 # B and D both number 1.1.
assert '<a href="#sec-b">Section 1.1</a>' in h and '<a href="#sec-d">Section 1.1</a>' in h
with pytest.raises(ValueError, match='needs a number'): # a title has no number to cite
mdhtml2html(md2mdhtml('# T {#sec-t}\n\nSee [@sec-t].'), number_headings='decimal')
g = md2gfm(src, number_headings='decimal')
assert g.count('## 1. ') == 2 and '# T2\n' in g # gfm bakes the same restart, title untouched
assert list(SCHEMES['legal'])[:3] == ['', '%2.', '(%3)'] # level 0 is the title; %2 is the h2 counter


def test_text_targets():
src = ('# Terms {#sec-t}\n\nThe [Term]{#def-term} governs.\n\nAgreement Period {#d-ap}\n: the deal period\n\n'
src = ('## Terms {#sec-t}\n\nThe [Term]{#def-term} governs.\n\nAgreement Period {#d-ap}\n: the deal period\n\n'
'See [@def-term], [@d-ap], and [@sec-t].\n')
h = mdhtml2html(md2mdhtml(src))
assert '<a href="#def-term">Term</a>' in h # span target: its own text, no prefix word
Expand Down Expand Up @@ -214,7 +232,7 @@ def test_id_prefix():
assert '<a href="#md-sec-a">x</a>' in h # user link to an in-fragment id follows
assert 'href="#_deadbeef"' in h # link to an id outside the fragment untouched
assert 'id="md-fnref-1"' in h and 'href="#md-fn-1"' in h and 'id="md-fn-1"' in h and 'href="#md-fnref-1"' in h
hr = mdhtml2html(md2mdhtml('# A {#sec-a}\n\nSee [@sec-a].'), id_prefix='p-')
hr = mdhtml2html(md2mdhtml('## A {#sec-a}\n\nSee [@sec-a].'), id_prefix='p-')
assert '<a href="#p-sec-a">Section 1</a>' in hr # resolve mode prefixes via fragment membership


Expand All @@ -237,23 +255,23 @@ def test_fn_salt():

def test_md2gfm_refs_and_numbering():
out = md2gfm(REFS_MD, number_headings='legal')
assert '# 1. Payment\n' in out and '## (a) Late fees\n' in out
assert '## 1. Payment\n' in out and '### (a) Late fees\n' in out and '# Agreement\n' in out # the title keeps no number
assert '{#sec-pay}' not in out
assert ('See Section 1, 1.(a), Clause 1.(a), Sections 1 and 1.(a), Section (a),\n'
'Late fees, and page 1.(a).') in out
auto = md2gfm('# A {#sec-a}\n\nSee [@sec-a].')
assert '# 1. A\n' in auto and 'See Section 1.' in auto
dl = md2gfm('# A {#sec-a}\n\nT\n: see [@sec-a].\n')
auto = md2gfm('## A {#sec-a}\n\nSee [@sec-a].')
assert '## 1. A\n' in auto and 'See Section 1.' in auto
dl = md2gfm('## A {#sec-a}\n\nT\n: see [@sec-a].\n')
assert ': see Section 1.' in dl # definition bodies are rewrite regions too
assert md2gfm('# A {#sec-a}\n\nText only.\n') == '# A\n\nText only.\n' # strip only; rest byte-identical
with pytest.raises(ValueError, match='not found'): md2gfm('See [@sec-x].')


def test_md2gfm_nested_containers():
md = ('# Top {#sec-top}\n\n::: box\n\n## Inner {#sec-in}\n\nBody.\n\n:::\n\n'
'> ## Quoted {#sec-q}\n\nSee [@sec-top], [@sec-in], and [-@sec-q]{ref=text}.\n')
md = ('## Top {#sec-top}\n\n::: box\n\n### Inner {#sec-in}\n\nBody.\n\n:::\n\n'
'> ### Quoted {#sec-q}\n\nSee [@sec-top], [@sec-in], and [-@sec-q]{ref=text}.\n')
out = md2gfm(md)
assert '# 1. Top\n' in out and '## 1.1. Inner\n' in out
assert '## 1. Top\n' in out and '### 1.1. Inner\n' in out
assert '{#sec-in}' not in out
assert 'See Section 1, Section 1.1, and Quoted.' in out
assert '{#sec-q}' in out # marker containers pass through unrewritten
Expand Down Expand Up @@ -367,9 +385,9 @@ def test_dialect_css_covers_pills_and_optional_preview_markers():

def test_lenient_refs_resolve_what_they_can():
h = mdhtml2html(md2mdhtml(LENIENT_MD), refs='lenient')
assert '<a href="#sec-x">Section 1.1</a>' in h # resolved, numbered, prefixed as usual
assert '<a href="#sec-x">Section 1</a>' in h # resolved, numbered, prefixed as usual (the h1 title takes no level)
assert '<a href="#nope" class="xref">nope</a>' in h # unresolved: an ids-mode link
assert '<span>Section <a href="#sec-x">1.1</a> and <a href="#gone" class="xref">gone</a></span>' in h
assert '<span>Section <a href="#sec-x">1</a> and <a href="#gone" class="xref">gone</a></span>' in h
assert sorted(w.split('#')[1].split(' ')[0] for w in h.warnings) == ['gone', 'nope']


Expand Down
5 changes: 3 additions & 2 deletions tests/test_typst.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ def test_code_and_math():


def test_refs_and_numbering():
t = T('# Pay {#sec-pay}\n\n## Terms {#sec-terms}\n\nSee [@sec-pay], [-@sec-terms], [Clause @sec-pay], and [@sec-pay; @sec-terms].\n')
assert '= Pay <sec-pay>' in t
t = T('## Pay {#sec-pay}\n\n### Terms {#sec-terms}\n\nSee [@sec-pay], [-@sec-terms], [Clause @sec-pay], and [@sec-pay; @sec-terms].\n')
assert '== Pay <sec-pay>' in t
assert 'if n.len() == 1 { "" }' in t and 'n.at(1)' in t # the title level prints nothing; scheme levels read from n.at(1)
assert '#ref(<sec-pay>, supplement: [Section])' in t
assert '#ref(<sec-terms>, supplement: none)' in t
assert '#ref(<sec-pay>, supplement: [Clause])' in t
Expand Down