diff --git a/README.md b/README.md index 9a3a898..602961d 100644 --- a/README.md +++ b/README.md @@ -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 `Section 1.`, 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 (`sec-pay`, 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 ``, 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 ``, 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 `Figure 1: ` 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 ``; `fr` values share the width remaining after fixed lengths. diff --git a/docs/DIALECT.md b/docs/DIALECT.md index 5ddb9ba..e7714df 100644 --- a/docs/DIALECT.md +++ b/docs/DIALECT.md @@ -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`. diff --git a/python/mdhtml/md.py b/python/mdhtml/md.py index 30df1ec..b2f1e5f 100644 --- a/python/mdhtml/md.py +++ b/python/mdhtml/md.py @@ -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)) diff --git a/python/mdhtml/typst.py b/python/mdhtml/typst.py index 352c373..45113da 100644 --- a/python/mdhtml/typst.py +++ b/python/mdhtml/typst.py @@ -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)" diff --git a/src/export_html.rs b/src/export_html.rs index fbb454e..52226de 100644 --- a/src/export_html.rs +++ b/src/export_html.rs @@ -251,6 +251,7 @@ impl Exporter { for &el in &self.heads.clone() { let lvl = ename(&self.dom, el).unwrap()[1..].parse::().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(); diff --git a/src/resolve.rs b/src/resolve.rs index 1492c8f..b04a553 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -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::>().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::(), "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(); @@ -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() } } diff --git a/tests/test_export.py b/tests/test_export.py index e0527ac..d2c9efd 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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}. @@ -26,17 +29,17 @@ def test_refs_and_heading_numbering(): assert 'page 1.(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 '1.1. Two' in d assert 'Section 1.1' 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 '1. A' in auto and 'Section 1' 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 'Exhibit 1' in h @@ -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('1.') == 2 # A and C both number 1. + assert h.count('1.1.') == 2 # B and D both number 1.1. + assert 'Section 1.1' in h and 'Section 1.1' 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 'Term' in h # span target: its own text, no prefix word @@ -214,7 +232,7 @@ def test_id_prefix(): assert 'x' 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 'Section 1' in hr # resolve mode prefixes via fragment membership @@ -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 @@ -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 'Section 1.1' in h # resolved, numbered, prefixed as usual + assert 'Section 1' in h # resolved, numbered, prefixed as usual (the h1 title takes no level) assert 'nope' in h # unresolved: an ids-mode link - assert 'Section 1.1 and gone' in h + assert 'Section 1 and gone' in h assert sorted(w.split('#')[1].split(' ')[0] for w in h.warnings) == ['gone', 'nope'] diff --git a/tests/test_typst.py b/tests/test_typst.py index fa1e832..1e83b07 100644 --- a/tests/test_typst.py +++ b/tests/test_typst.py @@ -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 ' 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 ' 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(, supplement: [Section])' in t assert '#ref(, supplement: none)' in t assert '#ref(, supplement: [Clause])' in t