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