[v3.1.0] List rendering robustness fixes (itemize / enumerate) - #422
Draft
OlgaRedozubova wants to merge 73 commits into
Draft
[v3.1.0] List rendering robustness fixes (itemize / enumerate)#422OlgaRedozubova wants to merge 73 commits into
OlgaRedozubova wants to merge 73 commits into
Conversation
These .d.ts files were emitted by an older tsc and only differ in tuple formatting (e.g. [number, number][]); regenerate them with the pinned 4.9.5 compiler so committed declarations match the toolchain.
A top-level itemize/enumerate only got its inline-start padding when the
widest \item[...] marker was measured. That measurement lived only in the
inline item path, so items whose content is a block environment
(\begin{figure}, \begin{tabular}, code fence) were skipped. When every
long-marker item held block content, the list lost its padding entirely.
Extract the marker-width calc into a shared computeMarkerPadding helper and
apply it in the block item path too (setTokenListItemOpenBlock now returns
the created token). Add regression tests for fenced-code and figure items.
Marker padding summed String.length, so a fullwidth/CJK marker like \item[11.] (U+FF0E, length 3) was undercounted versus its ASCII twin \item[11.42] (length 5) and fell under the padding threshold, leaving the list without indentation while a neighbouring ASCII list got it. Count East-Asian Wide/Fullwidth characters as width 2 (iterating by code point so surrogate pairs count once). ASCII markers are unaffected.
The Lists block rule parses speculatively into a buffered state, but that state shares env by prototype, so parsing mutates the real env.isBlock (and inheritedListType). On an unclosed list the rule returned false without restoring them; a leaked isBlock=true then let the inline list fallback fire on the following text, so an unclosed itemize before a tabular rendered a broken partial list with empty <> item bodies instead of plain text. Restore isBlock and inheritedListType when the speculative parse aborts, so the unclosed list degrades to text exactly as it does without a tabular.
The \footnote/\footnotetext block rules scan forward for their open tag; their
terminator set included the core markdown list rule but not the LaTeX list rule,
so a \begin{itemize} between a paragraph and a later footnote (no blank line) was
swallowed and rendered as literal text. Add the LaTeX list rule to the footnote
terminator set and use it for both footnote block rules.
Bumps version to 3.0.2; adds the list-rendering-robustness spec and changelog
entry covering this branch's list fixes.
Code-text styles were pinned to absolute values (`pre code` font-size 15px / line-height 24px / padding 1rem, `pre` font-size 85%), so a code block did not scale when a consumer sizes a rendered block via a single font-size on the container (e.g. image export): everything else scaled but the code stayed ~15px. Make them relative: `pre` font-size 0.9375em; `pre code` font-size inherit, line-height 1.6, padding 1em. Calibrated so a 16px base is pixel-identical except code padding (16px -> 15px). Styles only; no HTML change. Regenerate style snapshots, add a regression gate that rejects absolute font-size/line-height and rem padding for `pre code`; spec + changelog under 3.0.2.
…inators, perf guard - Restore env.isBlock/inheritedListType on both the abort and silent paths of the Lists block rule, so a silent terminator probe never mutates shared state. - \footnote uses a minimal fence+Lists terminator set (not the full set): fixes the list swallow with one cheap extra probe, avoiding a per-line cost regression. - Add a first-char fast bail to the Lists rule (no substring allocation) since it now runs as a per-line terminator in paragraph/footnote scans. - Measure markers by display width (East-Asian wide chars count as 2, BMP only). - Tests: silent env invariant, marker width edge cases (math/emoji/nested), footnote recognition after block constructs, and a scaling guard that rejects the O(N^2) footnote scan across unterminated lists. - Selector-scoped code-style gate; spec/changelog updated.
- Measure math markers by their rendered widthEx and wrapped markers (e.g. \textbf) by recursing into children, so such markers no longer get too small an indent (previously only top-level text tokens counted). - Extract the char-based width primitives (isWideChar, displayWidth, tokenDisplayWidth) into common/display-width.ts — the char-based counterpart of getTextWidthByTokens; computeMarkerPadding now delegates to them. - Round the padding px (marker width can be fractional once math width is included). - Tests: wide-math and bold marker padding, markdown list not swallowed before a \footnote; explicit non-empty guard on the code-style assertions. - Spec and changelog updated.
Empty `<>` item bodies from a leaked env.isBlock are fixed on both paths: the speculative list parse restores its transient env fields on every exit (abort/silent/commit/exception), and a tabular inside a list no longer carries those flags into its envToInline snapshot (which core-inline replays onto the shared env). Adds common/env-transient.ts as the single source of truth. Marker padding is emitted in ex, not px, so it scales with the container font-size like the marker's math SVG; this also fixes math markers being clipped (exact widthEx + gap, both ex). The marker gap (.li_level padding-right) and the default list indent move to ex/em. Marker width also trims edge whitespace and falls back to source length when widthEx is absent. Merges the two footnote terminator resolvers into resolveEnabledRuleFns. Adds tests/_display-width.js, an env-leak adversarial matrix, exact ex-value assertions, and updates fixtures/snapshot/specs/changelog.
Emit list marker padding-inline-start in em (was ex), converted from the ex
measurement via the default font metrics (EX_TO_EM = exDef/fonSizeDef). Custom
padding is emitted only when it exceeds the default (2.5em), so it never resolves
below it — no CSS max() or cross-unit comparison. The marker gap (0.625em) and
default indent (2.5em) move to em too; the attribute now carries its unit
("3.32em"). Math without a widthEx (non-SVG output) keeps the default indent
rather than a fabricated source-length estimate.
Text markers use 1.3 ex/char (reserve is per character cell, not per glyph);
this is ~5-14% tighter than 3.0.1 and wide all-caps markers can overlap —
documented in the spec Non-Goals and changelog.
Sync docs/comments with the code, add tests: EX_TO_EM vs fontMetrics, exact em
values, glyph-width limitation, MARKER_GAP_EM vs CSS.
A speculative (silent/aborted) list parse runs the body's \begin{figure} /
\begin{table}\caption and bumps the module-global caption counters, but its
tokens are discarded — so a \footnote after such a list, and the pre-existing
paragraph-terminator probe, shifted Figure N / Table N. Snapshot and restore the
counters when the parse is discarded: in the Lists block rule's finally and in
parseListEnvRawToTokens (inline path). A figure in a list is now Figure 1.
Extract the counters to the leaf module common/caption-counters.ts so the list
rule no longer imports the heavy begin-table module (removes an import-cycle
edge); the deep-import helpers Clear{Table,Figure}Numbers move there, renamed to
clear{Table,Figure}Numbers. Move the default font metrics (DEFAULT_FONT_SIZE_PX
/ DEFAULT_EX_PX) to consts as the single source for EX_TO_EM and FontMetrics.
Treat combining U+3099/U+309A as zero width in both isWideChar and displayWidth;
set committed before flush; drop the now-redundant CSS guard.
Bump 3.0.1 -> 3.1.0 (minor): the data-padding-inline-start attribute is now an
em value and caption numbering changes for existing docs (see changelog/spec).
Add regression tests: caption numbering (absolute, multi-float order, nested
list, \ref matches the caption number), attribute format contract, 3-char
threshold, combining marks in isWideChar and displayWidth.
Replace the flat per-character reserve with a per-glyph-class estimate in em (narrow / normal / wide / extra-wide W@% / East-Asian full-width; combining marks 0). Measured against real font metrics, the old flat 14px/char over-reserved narrow/digit markers by 36-70% and under-reserved all-caps by ~14%; the class estimate holds a +11..+27% margin — a tighter, correct indent for narrow/normal markers and a wider, safer one for all-caps. Math still uses the exact widthEx; short markers fall to the 2.5em default; the reservation is clamped at 20em so a pathological OCR marker can't blow out the content column. Only text-like leaves (text / code_inline / text_special) are measured, so a `code`-span marker now contributes its width while an html_inline marker's raw markup is not counted. Remove the now-unused displayWidth; dedupe the combining -mark predicate. Add a font-metrics lock test (Arial fixture): the rendered indent is never smaller than the marker's true glyph width + gap. Update fixtures, docs, and the attribute-format / clamp / mixed-marker tests.
A list's marker padding was accumulated as the max over all markers at every depth and written to the outermost list token, so a wide marker several levels deep drove the top-level list's indent (e.g. a deep [3.1.1.1] gave the outer list 4.31em while its own 1.–5. markers are narrow). Track a stack of open list tokens and attribute padding to the current (innermost) list instead. Nested lists now also emit and apply their own padding (drop the top-level-only gate in finalizeListItems and the level>1 suppression in the list renderers), so each level reserves for its own markers — a wide nested marker indents its own list rather than overflowing the container. The outer list reflects only its own markers (default when they're narrow). Add regression tests (per-level attribution; deep marker not bubbled up) and update changelog/spec.
Marker padding (on top of the per-nesting-level attribution): keep the default 2.5em indent on every list and emit a custom padding-inline-start only when a marker overflows the accumulated ancestor indent plus that default, reserving just the shortfall (need - ancestorIndent) so nested markers don't compound. Flat lists are unchanged (e.g. [11.33] -> 3.51em); ordinary nested lists (numbering, bullets) stay at the default; only a genuinely wide nested marker reserves extra, and less than its full width. Fix \item detection: the item-command regexes matched too loosely - LATEX_ITEM_COMMAND_INLINE_RE matched the bare word "item" (no backslash), and LATEX_ITEM_COMMAND_RE / LATEX_LIST_BOUNDARY_INLINE_RE matched any \item-prefixed command (\itemsep, \itemindent). A list-item body containing such text split into a spurious item, and a multiline \footnote/\footnotetext inside an item was broken mid-brace and rendered as literal text. All three now require \item plus a command boundary (\item[...] or \item(?![a-zA-Z])). Update changelog and the list-rendering pr-spec; add regression tests (_lists.js: \itemsep/\itemindent; _footnotes_latex.js: "item" in a footnote body; _list-marker-padding.js: per-level B2 attribution).
…ate padding style Resolve per-list padding in one top-down pass (resolveListPadding) after the whole environment is parsed, instead of emitting during finalize. finalizeListItems now only records each list's widest marker width; the pass computes the indent once every list's final width is known, so item order no longer skews a nested list (a wide parent item after the sublist resolves the same as before it). Clamp the total (ancestor + own), not just the added shortfall, to LIST_MAX_INDENT_EM, so cumulative indent never exceeds the clamp on a pathological nested marker. Validate data-padding-inline-start against /^\d+(\.\d+)?em$/ before inlining it as a style, so only a bare em value can reach the rendered CSS. Docs: the attribute is now emitted on nested lists (Migration); an empty parent item followed by a wide sublist marker overlaps as LaTeX does (Non-Goals, verified in Overleaf); image markers reserve by alt text; correct the code-block "16px" note for PreviewStyle's 17px base. Add regression tests (order independence, cumulative clamp, bold reserve lock).
Core-markdown <ul>/<ol> (no .itemize/.enumerate class) had no padding-inline-start, so browsers used their 40px default, which doesn't scale when a consumer sizes content by font-size. Add a scoped rule (#preview-content ul, #setText ul, ... ol) setting padding-inline-start: 2.5em, matching the LaTeX-list default and scaling with the container. Scoped only per 2026-03-mmd-css-scoping.md — a bare ul/ol would restyle the host page. LaTeX lists are unaffected (their class rules and inline padding win by specificity, same value); TOC is safe (separate #toc_container; the in-content .table-of-contents is display:none). Regenerate the listsStyles snapshot; update changelog and the list-rendering pr-spec.
…oped-only Follow-up to the markdown-list padding rule (bc57340): - The rule also covers the generated footnotes list (<ol class="footnotes-list">), which had no explicit padding — disclosed in changelog and the pr-spec/Migration. - Migration: the wrapped ul/ol padding floor rises from the UA default (specificity 0) to (1,0,1), so a consumer's class-specificity rule no longer overrides the indent. - Spec: the #toc_container-over-markdown precedence rests on bundle order (lists before toc), not specificity (both 1,0,1) — documented, with the specificity-boost option. - Test: assert listsStyles has no bare ul/ol selector, so a future edit can't quietly regenerate the snapshot with one.
The markdown-list rule (#setText ul { padding-inline-start: 2.5em }) and
#toc_container ul { padding: 0 } are both (1,0,1), so which wins depended on bundle
order (lists before toc). TocStyle is only emitted when toc is enabled, so a consumer
that renders a visible #toc_container inside the wrapper without TocStyle got 2.5em on
the TOC list.
Add a guard in the always-emitted lists module — #preview-content #toc_container ul/ol,
#setText #toc_container ul/ol { padding-inline-start: 0 }, specificity (2,0,1) — so the
TOC list stays at 0 regardless of order or whether TocStyle is present. It matches any
ul/ol inside a wrapper-nested #toc_container (so it also outranks .itemize (1,1,0); a
theoretical LaTeX-in-TOC would be zeroed too — accepted over a :not(.itemize) carve-out).
Also address review follow-ups: correct the clamp wording (the reserved indent is
clamped to 20em, the per-level default step adds on top); widen the no-bare-ul/ol
tripwire to catch post-comma and combinator forms; regenerate the listsStyles snapshot;
update the pr-spec.
… test fixtures Attribute marker padding through one shared registry (openTokens + allListTokens) seeded in ListOpen and threaded into ListItems/processListChildToken, so all list forms resolve identically — the block loop (own-line nested), the same-line form, and the fully single-line / in-cell form (which the block Lists rule bails on and ListOpen now handles, resolving on its single-line early-return). A wide nested marker no longer inflates the outer list regardless of how the list is written, and a single-line / in-cell list reserves for its marker too. Review follow-ups: remove the dead `padding` plumbing (ListItemsResult / ListInlineContext field, fallback branches, ListOpen local); make openTokens / allListTokens required (no silent-no-op default); fill skipped depth before the ancestor sum and clamp depth >= 0 in resolveListPadding (no RangeError / NaN); fix the stale "for top-level lists" render comments; note the inline-opened *_list_open prentLevel (0 -> depth) in Migration. Convert the rendered-HTML tests to full-HTML fixtures (catch visual regression the way the _data-driven tests do): marker padding, B2 nesting, empty-<>, \item detection and footnote-terminator cases -> tests/_data/_lists/_data.js; caption numbering -> _data/_captions/_data.js; "item"-word footnote body -> _data/_footnotes_latex/_data-footnotetext.js. Keep the non-renderable tests (display-width units, config-varying renders, env-state, the Arial glyph-width invariant, resolveListPadding unit, the scaling test) as targeted assertions.
Changelog: cut spec-level internals (per-glyph constants, px measurements, the 3.0.1 over-reservation percentages, internal probe/env mechanism) from the 3.1.0 list-fix bullets, keeping what changed plus the migration — matching the concise style of earlier entries. pr-spec: rewrite the Testing section, which described tests that were moved into full-HTML fixtures (the list-rendering, caption and footnote-body cases), to match the actual layout; drop an intermediate example value from a Non-Goal.
Follow-up fixes on the 3.1.0 list work, addressing review findings. Table-cell export: - A list written entirely on one line inside a table cell unwraps its item body to bare inline tokens (not an `inline` wrapper), which the cell renderer only sent to HTML — so table-markdown/tsv/csv/smoothed dropped the body (`\item[x] d` exported as `x `). The leaf branch now collects the whole consecutive run through renderTableCellContent, matching the multi-line form. - A markdown link in a `tabular` cell skipped two tokens too many, leaving its `</a>` unemitted and absorbing following siblings. It now walks to its own link_close by depth. Pre-existing; unrelated to lists. Speculative-parse hardening: - Caption env (caption/captionPos/captionIsLabelFormatEmpty/ captionIsSingleLineCheck) written by nested float rules is now snapshotted and restored on a non-committing exit, alongside the caption counters; closed by audit (no reproduced defect). Shared snapshotEnvKeys/restoreEnvKeys helpers. - openTokens pop on an inline `\end` is now by token identity, so the block and inline paths can't pop the same list twice. - Silent Lists probe result is memoized per state (invalidated on state.src reassignment), so paragraph/footnote terminator scans don't re-run the whole speculative parse per line. Output is byte-identical with and without the memo. Cleanups: - resolveEnabledRuleFn resolves the single list terminator without a Set/array. - resolveListPadding uses a local instead of the vestigial token.indentEm. - Merge duplicate consts imports. Tests: single-line list in a cell (table-markdown/tsv), link-in-cell, the padding-style sanitization guard, and silent-probe memo isolation. Docs: changelog + specs (clamp wording, margin range/font, migration notes).
Widen the speculative-parse env rollback beyond the caption keys. A list body parsed speculatively (silent probe / aborted parse) runs nested float and tabular rules that write more shared-env keys than caption alone: begin-table also writes envType/align/alignEnvBlock/number/type, and begin-tabular writes isInline/subTabular/tabulare. A silent probe leaked all of these. - Rename LIST_SPECULATIVE_CAPTION_ENV_KEYS -> LIST_SPECULATIVE_ENV_KEYS and add the float/tabular keys, so the set can't drift from its write sites. Still restored only on a non-committing exit; a committed float keeps them, as it does without a list (pre-existing). Tests: - _parse-isolation: a silent Lists probe over a body holding a figure / an unclosed table leaves env untouched (full key+value snapshot) — fails without the widened set. - _list-marker-padding: the default-indent overflow threshold (narrow glyphs straddle it). - _lists fixtures: sibling sublists resolve independently in either order (only the wide one reserves). Docs: spec fix 3 lists every key and its write site; changelog/migration note the committed-float persistence and the concrete table-markdown/marker diffs.
Three behaviour-preserving optimizations for list-heavy parses, plus docs. - textReserveEm: measure ASCII (the common case) through a precomputed glyph-class lookup table instead of running the class regexes per char. The table is built from the same regexes so it can't drift, and iteration moves to code units with an explicit surrogate skip (astral chars still count once). Verified equal to the old path across ASCII/combining/CJK/astral/surrogates. - restoreEnvKeys: set previously-absent keys to `undefined` instead of `delete`-ing them; `delete` moved the shared parser `env` into dictionary mode for the rest of the parse. All readers test the value, so behaviour is unchanged — but a consumer inspecting its own `env` after a parse now sees the keys present with value `undefined` (documented in changelog/spec/migration). - safeAssignToken: hoist its skip Set to a module constant (was rebuilt per flushed token). Kept the constant at the top of the file so the exported function keeps its JSDoc in the generated .d.ts. Tests updated to assert env leakage at the value level. Docs note the perf change and the consumer-visible `undefined`-vs-absent env difference.
Docs only; no code change. - changelog: re-measure the parse-speed bullet and drop the earlier overclaim. The memo helps where a terminator scan re-probes the same line, so the win is on repeated paragraph + list + footnote units without blank separators (200 units 47 -> 24 ms, 400 units 152 -> 43 ms, now linear); a plain list-heavy document is roughly unchanged (4119 lines: 60 -> 58 ms). Keep the env-rollback migration note. - spec: state the memo measurement as an on/off isolation (34.4 -> 20.4 ms on the re-probed shape; no effect on blank-separated lists), drop the unverifiable "33-shape matrix" phrase, and replace "separate ticket" with "out of scope here". Refresh the Testing section for the tests that landed (sibling sublists, the default-indent threshold, the padding-style guard, the figure/table env-isolation cases).
- textReserveEm: classify non-ASCII BMP letters by case (uppercase extra-wide, the rest wide) instead of counting them as narrow, so uppercase Cyrillic/ Greek/accented-Latin markers are no longer clipped by the item text; widen the zero-width set to the real combining-mark blocks (a decomposed accent now costs only its base glyph); cache the class per code point. ASCII stays on the lookup table. Behaviour for ASCII is unchanged. - ItemsListPush: split a line on a real \item (LATEX_ITEM_SPLIT_RE), so a mid- line \itemsep no longer starts a spurious item and drops the space before it. - resolveListPadding: carry a prefix sum instead of re-reducing the ancestor indents per level (equivalent output, O(1) per token). - snapshotEnvForInline: build the snapshot by copying wanted keys instead of spread-then-delete (delete dropped env into dictionary mode); carry symbol keys through. - list-state: warn once per parse for the depth-desync diagnostics, so a silent probe can't flood a consumer's log. - styles-lists: hold the in-content .table-of-contents list at padding 0 too. Docs: changelog + spec cover the by-case width model (with the measured under/over-reserve trade-offs), the item-split anchoring, and the perf notes. Fixture comment corrected (astral emoji land in the wide class, not normal).
- textReserveEm/tokenMarkerWidth: measure code_inline and \texttt{…} markers at
a flat 0.62em/char (monospace advance) instead of the proportional glyph
classes, which underreserved narrow chars in a `<code>` face; cache only the
non-ASCII case test (a Map, covering astral too) rather than a 64 KB table;
a lone surrogate reserves 0.
- Lists probe memo: include the line's content offset (bMarks+tShift) and
blkIndent in the key, so a blockquote — which shifts those for the same line
numbers on one state — can't return a stale answer.
- processListChildToken: on the inline path, pop the shared registry only when
the top is a list-open of the matching kind, mirroring the block path's
identity check, so an unpaired close can't misattribute later markers.
- list-state: report each distinct list-depth diagnostic once per parse, capped
at five, instead of one blanket message — a different depth is new info.
Docs: spec/changelog cover the monospace width model and the probe-key inputs.
The icon demo pages embed a copy of the stylesheet, and 2eb05a7 updated the list and code-block rules in it. That was pointless: none of the eight pages contains a <ul>, <ol> or <pre>, so every edited rule matches nothing there. Restore all five files to their master state.
- resolveListPadding: round the reserve up (Math.ceil), never to nearest, so a
marker's indent can't land a hundredth below what it needs.
- snapshotEnvForInline: drop undefined-valued LIST_SPECULATIVE_ENV_KEYS from the
snapshot so replaying it can't clear a key that went live after the rollback
(reproduced: env.align ended undefined instead of "center" when a list-with-
tabular preceded \begin{center}). The begin-tabular trio (isInline/subTabular/
tabulare) is exempt — the tsv/csv export needs their undefined to reach replay.
- tokenMarkerWidth: count monospace cells (code points, combining marks 0) for
code_inline/\texttt markers instead of UTF-16 length; drop code_inline from
TEXT_LIKE_TYPES since the monospace branch handles it.
- list-state: warn once per distinct case, no separate cap.
- README: note that lib/ and es5/ are committed build artifacts (build, don't
compile, before committing) and how to check the tree.
Docs: spec/changelog updated — the env-replay fix (with the reproduced case),
the ceil'd fixture value, the by-case width model, and the release bundling.
- table-markdown getMdLink: build a link label by walking to the matching link_close instead of reading only the token after link_open, so a formatted label survives the export (`[**b** x](url)` was `[](url)`). Per inner token: text with `]` re-escaped, code_inline/smiles with their markers, else the getMdForChild marker or its content (keeps image alt / inline-math LaTeX). - render-table-cell-content: also feed the consumed link tokens to the smoothed accumulator, so pptx no longer keeps an opening `<a>` with no text or close. - Lists probe memo: key on the first line's end offset (eMarks) instead of blkIndent — the rule never reads blkIndent, and eMarks pins the content more tightly; the geometry still distinguishes a blockquote's shifted lines. - render-tabular: only build the leaf run when an export (tsv/csv/md/smoothed) is requested; an HTML-only render was doing it for nothing. - block-rule: fold the single list terminator back into resolveEnabledRuleFns (one resolver, not two). Docs: changelog/spec cover the link-label composition (with the image/math/ smiles/escaping behaviour) and the probe-key inputs (blkIndent excluded, tested).
…weep A list with more `\begin` than `\end` ahead used to walk to EOF on every probed line — the entry-point lookahead only checked that some closer exists, not enough of them. Inside the body walk, after each nested `\begin`, abort when the closers left ahead are fewer than the envs now open. The closer sweep over-counts (a `\end` in a fence is not real), so `count < open` proves closure impossible and never cuts a list that would have closed. - src-pos-cache: add matchPositionsCached (all match offsets, ascending) and countPositionsAtOrAfter (binary search). - latex-list-env-block: one closer-offset sweep per document now feeds both the entry bail (last offset) and the in-walk depth check (count ahead), replacing the separate last-position cache. Docs: changelog performance note and spec fix 4 cover the second short-circuit.
… scan - Lists: remove the silent-probe result memo. The closer lookahead and the in-walk depth check make a probe cheap enough that the memo measured slower on every shape, and dropping it removes the burden of keeping its key complete. - env-transient: take the whole-env snapshot from a LIFO pool (released in the same finally), so a list env allocates nothing; one snapshot now serves both the always-on transient restore (restoreEnvKeysFromAll) and the discard rollback. Self-guard the throw in the rule; the inline path guards its engine call the same way, via a shared warnListRuleFailed. - latex-list-env-inline: match `\item` and the item boundary at an index with sticky/global regexes instead of `src.slice(pos).match`, which copied the rest of the document each call. - Fixes: a `\renewcommand` line no longer leaves an orphan `<br>`; an image cell no longer glues its alt onto its src in tsv/csv; getMdMath shares the math delimiter logic; SMILES tags are constants. - Micro-opts: isListType via a Set, resolveListPadding early-out, safeAssignToken loop; resetWarnDistinct runs from resetMmdGlobalState. Docs: spec fixes 3/4, non-goals and testing drop the memo and state the pooled snapshot, the in-rule/inline guards, and the probe-consistency tests.
…anups - env-transient: releaseEnvSnapshot now clears the pooled slot's arrays — the pool outlives the parse, so a retained value kept that document's env objects alive. Drop the now-unused snapshotEnvKeys/restoreEnvKeys (Lists moved to restoreEnvKeysFromAll). Note that the rollback diffs by identity, so a key whose object is mutated in place is not restored. - warn-distinct: reset it from the per-render resetHook (before the partial- render bail), not from resetMmdGlobalState which a partial render skips — else a per-block re-render warns once per process. - getMdMath takes the trimmed content as an argument instead of a spread clone; getMdLink skips a nested link_close explicitly. - Move the list closer-offset consts above ListsInternal, which uses them. Docs: spec adds two non-goals (a cell-exported list ends with a `<br>`; the env rollback is by identity) and notes the identity compare in fix 3.
Docs only. Fold the sprawling spec non-goals into fewer grouped ones — the marker-width caveats move under fix 2 where they belong, the isolation caveats (env identity compare, diff cost, registry-pop asymmetry) into one non-goal, the cell-export quirks and the diagnostics/degraded-signal notes each into one. The spec's migration section now points at the changelog as the canonical list instead of duplicating it. Expand the testing section (mutation-checked invariants) and the changelog breaking changes (prentLevel, math delimiters, trimmed marker, bare `\item` in a cell, the merged deep-import removals). No behaviour claim changed; the dropped leaf-run note was a negligible perf aside.
…eanup - render-table-cell-content: write an image destination through mdHref in the main cell loop too, matching the label path, so a src that needs the `<…>` form gets it the same way in both. Flatten the tsv/csv branch now that the image case is a plain guard. - table-markdown: export mdHref for that shared use. - Comments: the Wide-emoji ranges cite EastAsianWidth 15.1; a committed list needs no `types` copy-back (its walk is balanced); the cell continuation-space heuristic only under-adds, never over-adds.
A line with more than one inline env transition was handled one at a time: a
collapsed `\end{itemize}\end{itemize}` closed one level and left the rest to
ItemsAddToPrev, which drops a pure closer, so the outer list never closed and
the strict no-partial-tokens bail dropped it as literal text.
- Walk all \begin/\end on the line left to right (nextListEnvMatch); the tail
strictly shrinks each match, so it terminates. A closer mid-line may open a
sibling list, not only a nested one.
- A sibling opens only when the closers ahead cover what its tail leaves open —
counted (unclosedEnvsIn), and only those before the next fence opener, since a
`\end` in code is text; otherwise the finished list is kept and the tail is
dropped, as before. On a top-level sibling, put `parentType` back so it is not
read as nested and docked one default indent.
- src-pos-cache: add firstPositionAtOrAfter.
Docs: spec fix 11 and the changelog describe the source-order walk, the counted
sibling guard, the parentType reset, and the one benign change on valid input
(a whitespace-only node between `<ul>` and `<li>`).
getMdMath no longer special-cases display math: cell/label math takes the `outMath.table_markdown.math_inline_delimiters` pair, defaulting to `$…$` for inline and display alike (the option's documented default; a cell renders math inline anyway). A caller that wants the block form passes `['$$','$$']`. This drops the `$$`-preservation added earlier — `$$x$$` in a cell now exports `$x$`, which round-trips to inline; disclosed in the changelog with the opt-out. Tests and the sub-math fixture updated to the single-delimiter behaviour, plus an assertion for the `['$$','$$']` escape hatch.
A no-output `\renewcommand` line between items joins the previous item without a break so it leaves no orphan `<br>` in HTML — but under `forLatex` the line is source to rebuild, so the break must stay. Gate keepLineBreak on `options.forLatex`: HTML unchanged (no break, no orphan), forLatex keeps the line on its own. Tested through the plugin, since markdownToHTML drops forLatex before the plugin runs.
- restoreEnvAll: detect a vanished key with hasOwnProperty, not `in` — a key named like a prototype member (`toString`) would otherwise read as still present after the parse deleted it, so its restore was skipped. - mdHref: a backslash in the destination now forces the `<…>` form too; a bare `(a\)` lets the trailing backslash escape the closing paren and leaves the link open. - Add tests/_src-pos-cache.js: the count/first-position binary searches decide list structure (a sibling opening, the body walk closing, a closer ahead of a fence), so an off-by-one changes HTML — pin the boundary, duplicates, empty input, cache invalidation and the empty-match guard directly.
A chunk holding a `table`/`figure` float but no `\item` — the float after a
closed nested list, or before the first item — fell to the inline path, so
`\begin{table}` leaked as literal text, `\captionsetup` left a stray `}`, and
the caption was dropped. finalizeListItems only block-parses when the content
has both a block env and an `\item`; add a floats-only branch for the no-`\item`
case, block-parsing into the already-open item with no new item token.
Narrowed to floats via RE_BEGIN_FIGURE_OR_TABLE_ENV on purpose: tabular,
lstlisting and fences already render from the inline path, and routing them here
lost four fixtures (a fence printed its backticks). Both shapes are valid LaTeX
(pdflatex accepts a float before the first item), so this is a correctness fix.
Docs: spec fix 12 and the changelog; the non-goal now separates a float (fixed)
from plain text (not) before the first item.
Widen fix 12 from floats-only to the same LATEX_BLOCK_ENV_OPEN_RE the marker case uses, so a block env in a list body renders the same wherever it sits. table/figure/center/left/right lost their wrapper in a markerless chunk, and a tabular there rendered without the `table_tabular` wrapper it has at top level and after an `\item` — wrong output, now corrected. Backtick/tilde fences stay on the inline path: this regex doesn't match them, and block-parsing them printed the backticks. Drops the now-unused RE_BEGIN_FIGURE_OR_TABLE_ENV import; one lists-inside-tabular fixture updated for the tabular wrapper. Docs: spec fix 12 and the changelog describe the unified gate and the corrected tabular wrapper.
The body walk reads `\item`/`\end{itemize}` textually, so a list command inside
a `table`/`figure`/`center`/`left`/`right` was taken as structure — `\caption{\item[a]}`
left no items, a stray `\end{itemize}` closed the list early, an `\item` in
`\begin{center}` added one. Those five join tabular and lstlisting on the opaque
stack: their lines collect raw until their own closer (a nested `\end{tabular}`
in a `table` is content, the stack top picks its pattern), and the block parse
builds the wrapper from the raw text. The input is invalid LaTeX, so the aim is
well-formed degradation — the command stays visible as caption text — not
fidelity.
Opening is guarded: a wrapper turns opaque only when its `\end` is ahead, or the
rest of the list would be swallowed as raw text; that decline path is pinned.
OpaqueEnvType (latex-list-types) gains the five envs.
Docs: spec fix 13 and the changelog; fix 12's caption caveat is now this fix.
…sted depth, cache marker macros and sweeps on env
- Opaque guard weighs closers by brace depth: \end{itemize} inside \caption{}
is text, unmatched { falls back to counting all (master behaviour)
- Wrapper env opening and closing on one line is read on that line
- List inside a wrapper keeps its nesting depth (reset only when no list open)
- Marker macros parsed once per macro per parse, cached on env; forDocx uncached
- Source-position sweeps hosted on env so a buffered probe reuses them
…r set from one list
- findEndMarker reads a shield by backslash parity, not the previous char:
\caption{x \\} and \section{title \\} render now (was dropped whole)
- Opacity guard decides by merge-walk order, not a tally: a closer standing
first is ours even when closer/opener counts balance; window bounded at 4096
- Wrapper names, closer patterns and cache keys derive from LATEX_BLOCK_ENV_NAMES;
wrapperBeginAt finds the first wrapper, not the first block env
- Marker-token bucket keyed by md/outMath, hands back an array copy; sweep cache
refreshes an entry on hit so the outer document's survives nested sources
- Render depth zeroed per render in the plugin hook; render rules fix only
negative drift
- New tests/_find-end-marker.js covers the parity rule
- A chunk before the first \item (text, unsupported command, fence, block env)
was a direct child of <ul>, which admits only <li>; it now gets a marker-less
<li data-marker-empty> — not_number/display:block inside <ol> so the browser
does not count it. Applied after the run emits, so nothing-emitted gets no <li>
- Synthetic block state carries state.Token, which wrapLooseRun builds through;
without it a list in a paragraph or table cell dropped to literal LaTeX
- hasCloserAhead takes the \begin offset, not the line start, so an \end{X} left
of the opener no longer reads as reachable
- Bound tokenMarkerWidth recursion and the warn-distinct key set
- Factor the <li> open-tag into openItemTag; move RENEWCOMMAND_LINE_RE to consts
…nges
- New common/verbatim-ranges: fenced blocks, lstlisting, inline code and math
(per paragraph) located once per source; a brace or \end{itemize} written in
any of them reads as text, replacing the unmatched-{ "count every closer"
fallback and the fixed 4096-char window
- New common/math-spans: one math scanner for the tabular extraction and the
list guard, so the two cannot disagree
- A closer of ours inside a wrapper is content when the source past it still
closes the open lists and no list starts inside; absorb a sublist written in
a loose chunk into its marker-less <li>
- processOpaqueLine terminates on a non-shrinking tail and warns instead of a
fixed step cap; footnote terminator set resolved once per ruler state, and the
Lists probe runs only with a list opener ahead
- src-pos caches gain a hot slot and are emptied per render with the snapshot
pool; remove dead ListItemsBlock
… brace pairing
- Move findEndMarkerPos to common.ts (mdPluginRaw re-exports it) so
common/math-spans reads it without importing the plugin — the cycle left it
undefined at load and emptied the render; also make it iterative, a long run
of escaped markers overflowed the recursion
- findVerbatimRanges unions overlapping kinds (inline code crossing a fence)
so isInsideRanges binary search never sees an overlap
- pairArgumentSpans is one stack pass over the source, exported and tested;
a run of unmatched { was n^1.9 through findEndMarker-per-brace
- src-pos cache values wrap in a slot, so a cached undefined reads as present
- forDocx outMath mutation restored in a finally; a throw used to leave it on
the md instance for every later render
- Nested lists carry line numbers under lineNumbering; restore state.startLine
- New tests/_math-spans.js and tests/_verbatim-ranges.js
…pen helper
- openOpaqueEnv is the one place both openers decide whether the env closes on
the same line; the nested-tabular branch had only the opening half, so
A & \begin{tabular}{l}x\end{tabular} \\ in a cell left the stack open and
printed the whole list as literal LaTeX
- The opaque-env loop terminates by construction, so its trailing warn asserts
the invariant rather than guarding a step count
- \setcounter value is stored as a number on the inline path too (was a string
that only worked because consumers stringify it); li.value narrows to number
…render - snapshotListLevels/restoreListLevels deep-copy the level stack instead of its length: a truncate put back neither a level a discarded parse removed nor the openItems it counted on a surviving one, and that count decides whether a chunk before the first \item gets a marker-less <li> - hasCloserAhead reads getOpenListCount() (the live open count) rather than a depth captured at entry - clearMarkerTokens drops the per-macro token bucket in the per-render reset, so a rule writing to a shared marker token cannot reach the next render; within a render the sharing stays (cloning measured 12-29% slower) - Seed state.types before push, for an inline state a foreign rule built without it; cache [fence]+terminators; warn once on a non-em padding attribute
…ostics cap - hasCloserAhead scans every closer ahead and skips those inside a fenced block or lstlisting, deciding on the first real one; taking the first textual match left a wrapper transparent while its real closer stood below a fake one in code - handleLstEndInline uses the same firstUsableCloser rule when inside an opaque env - warnDistinct caps per cause family (40) as well as overall (200), so a flood of one kind cannot silence another subsystem's single warning - Build the four \item/list-boundary regexes from one source string, add a sticky variant; drop the now-unused firstPositionAtOrAfter - README: a Security notes section on the Markdown exports (link scheme is the reader's job)
… and sticky regex - structuralCountIn reads a cached per-source suffix-sum instead of walking the range per wrapper, which was super-linear on a document full of wrappers - restoreEnvAll returns instead of restoring from a snapshot that is not the innermost one: a pool reset had emptied it, and restoring blanked the consumer's own env keys - makeItemCommandSticky hands each caller its own sticky regex, so a shared lastIndex cannot leak between them - resolveListPadding warns once per depth when a marker indent hits the 20em clamp - README: note that cell exports carry inline markup, raw HTML included, verbatim
…ral sibling count - snapshotIsUsable gates restoreEnvKeysFromAll too, not only restoreEnvAll: a transient-key restore from a pool-reset snapshot would blank the consumer's keys - A release_mmd_src_caches core rule clears the src-pos and marker caches when the chain ends, so a consumer's env no longer holds a document's offset arrays until the next render (~260 KB on a 29 KB document) - The sibling-closable count uses structuralCountIn, so a closer in code or in a command argument does not qualify, matching every other reader - shouldSkipDollar's trailing-digit check applies to single $ only; for $$ the position it read was the second $, never a digit - sub-math imports findEndMarkerPos from common; leaveListLevel keys its warning by depth
- Move the list rule's view of its source text — verbatim ranges, argument spans, structural closer/opener counts, wrapper-closer reachability, opaque-env patterns — from latex-list-env-block into md-latex-lists-env/list-source-model, so the four readers of "is this closer structural?" live together and cannot drift. Behaviour is unchanged; pairArgumentSpans is exported there for its test - absoluteOffsetOf returns -1 when its anchor does not hold; the wrapper guard declines on -1 and firstUsableCloser takes the closer, as before - snapshotEnvForInline keeps every non-transient key, undefined included, matching the spread it replaced, instead of an allowlist of undefined keys to replay - terminatorsWithFence caches per name set, like resolveEnabledRuleFns - Pin the crossed-env one-line quirk in _data_known_quirks.js, out of the <li>-only sweep it breaks on purpose
…walking the tail - writtenAsText (verbatim or command argument) is the single "is this text?" predicate; hasCloserAhead and firstUsableCloser now skip an argument closer too, not only a code/math one, matching closesOurListWithin and the sibling count - unclosedEnvsIn walks the tail as the parse loop does, so a closer in a code span no longer counts and a sibling that could never close is not opened - mathOpenerOffsets sweeps the openers once; findVerbatimRanges skips a paragraph with no math instead of scanning to EOF per block (126 ms -> 1.2 ms on prose) - resetEnvSnapshotPool keeps the pool when a snapshot is live (nested render); snapshotEnvAll releases its slot if the read throws - table-markdown trims math in a link label, as the cell loop does; move splitInlineListEnv into list-source-model; drop dead non-global regex resets - Code-block styles: a raw-HTML <pre> with no <code> now takes 15px (13.6 -> 15)
…require warn message - restoreEnvKeysFromAll blanks a key the parse added but leaves absent a key neither side ever had, so Object.keys(env) matches 3.0.1 again — only the value differs where a parse leftover used to show - The sibling-closable check counts closersLeftAfter (free closers, minus the openers that claim them), matching hasCloserAhead, not every structural closer - resolveEnabledRuleFns does not cache while the ruler cache token is null (a toggle in flight), an entry that could never be hit - warnDistinct takes message as a required parameter; encapsulate structuralCountIn and CLOSER_SUFFIX_KEY, export closersLeftAfter - Docs: tsv/csv carry a link's href then the rest of the cell; new console.warn diagnostics; sub/sup/ins kept in a plain cell; the \\ fix holds for every command
…nv by what is open
- listHostFlags walks a container stack (open list vs open item) to decide which
list opening straight inside a list gets a marker-less <li> and closes with one,
replacing the prevToken/nextToken guesses that missed a list after a sibling
close and broke on an unbalanced slice
- \end{itemize} over an open enumerate now closes with </ol>: setTokenCloseList
and listCloseInline read the open type, not the command name, so no closing tag
is left without its opener
- pairListTokens pairs item and list tokens in one pass (was O(n^2) for absorb);
extractNextBraceContent and the cell marker-less item go through findEndMarker
- Extract the opaque-env handling into latex-list-opaque; snapshotEnvAll runs
inside the try so a throwing env getter declines the rule, not the render
Reporting a problem must not be the thing that fails, and restoring `env` must not outlive its own failure. - warnDistinct checks `console.warn` per call. A headless runner leaving it unset threw from inside the handler that reports a failing rule, so the document lost the whole list instead of one warning. - The `finally` in Lists wraps the whole restore and releases the pool slot in its own `finally`. A getter-only or frozen key made the write-back throw, skipping `releaseEnvSnapshot()`; since the per-render reset declines while a snapshot is live, that slot stayed claimed for the process. The failure is now reported as `env-restore-failed:` rather than propagated, which matches 3.0.1 (it never wrote back at all, so it never threw). - A wrapper that closes on its own line hands its tail back to the opaque walk when the tail actually shrank. A second wrapper beside it reached the caller as text and kept the brace that opened it (`}y`); 3.0.1 dropped it. - Drop the dead `snapshotDepth = 0` after the early return in resetEnvSnapshotPool: release never lets the depth go below zero. - Cache the list host flags by length plus the two end token types, not length alone. Tests: new tests/_list-source-model.js unit-tests the four readers of the source model and the opaque walk directly (12 cases, including a property test that the open-env count walks a tail the way the parse loop does); _parse-isolation gains a missing-console case, a hostile-env case and three registry checks (heading slug, label, footnote register once through a speculative probe). Fixtures: two wrappers on one line, and a closer written in math outside a wrapper pinned as a quirk. Docs: the changelog no longer claims a math closer is text outside a wrapper, names the two-wrapper fix, and warns tsv/csv readers and anyone catching an exception in a block of their own. README documents how the diagnostic cap behaves off the markdownToHTML path, that `[TexConvert]` is MathJax's own channel, that a rule must be swapped through `ruler.at`, and that `env` should stay small. /pr-specs no longer ships in the tarball.
CI failed with a 2000ms timeout on the fixture sweep. Two causes, both mine. - The DOM-based sweep added last round duplicated `invalidChild`, which walks every child of every list element and not only the first — the "stops at the first child" limitation belongs to the regex check in the fuzz harness, not here. Verified by removing the host-flag fix: both sweeps failed on the same fixture, so one of them says nothing new. Removed, and the gate is still there: without the fix `holds across every list fixture` fails and names the shape. - The three sweeps in that block each rendered all 218 fixtures, 1.3s of the 2s budget before any assertion. They now share one render: the block runs in 293ms against 729ms.
`$|x|$` exported `| |x| | 2 |`, a row a Markdown reader cuts into three
cells against a two-column header. The ascii branch now escapes like the
path beside it, and both read one chain through `asciiForMarkdown`, so they
cannot disagree on a token carrying only `ascii_md`. Reachable from `|x|`,
`\left|…\right|`, `\vert`, `\|` and a `|` inside `\text{}`; `\mid` is
U+2223 and unaffected. `tsv`/`csv` keep their own chains — a pipe is legal
in both. Over 338 documents that export table-markdown, one changes.
Review follow-ups, none of which move output: `\setcounter` on the inline
path falls back to 1 for a non-numeric argument as the block path does;
`wrapLooseRun` loses a parameter no caller passed; the itemize reset reads
`<= 0` like the enumerate branch beside it, where at zero it assigns zero.
Fixtures: five in `_data/_table-markdown` for the pipe, one of them `\mid`
holding the escape narrow; one in `_data-footnotetext` for a list before a
multi-line note. Five unit tests pin the `absorbSublistIntoWrapper` guards
that no document reaches — two are load-bearing, one of them what keeps the
walk terminating.
Spec: perf re-measured with its method and spread, the `\footnotetext`
terminator claim corrected to match the code, long bullets split and their
narration trimmed.
`findVerbatimRanges` clipped such a span to the blank line and kept it, so the
paragraph tail after an unpaired opener read as math. Everything asking whether
an `\end{itemize}` is text then answered wrong for that stretch: a marker in any
later paragraph disabled the wrapper guard for the text between them, the list
leaked `\begin{center}` and `\end{itemize}` as literal LaTeX, and the item after
the wrapper was lost. Nothing warned — the rule parsed, it just parsed differently.
The window rule now follows what pairs inside one inline token rather than a list
of markers: `$`, `$$`, `\[`, `\(` and both double forms. A math env keeps its
clipped span, its body being allowed to span paragraphs, and that clipping is
what still covers a closer written in the env's first paragraph. Verified for
every opener `RE_MATH_OPEN` admits: `\[` and `\(` render as text across a blank
line exactly as `$` does, so an unpaired one is not math.
One concept, one definition. The six list-structural token types were spelled out
in four places, one of them a copy this branch added under a second name; they now
come from `common/consts` and the structural set is built from the open and close
sets, so a seventh type cannot reach one reader and miss another. `structuralSuffix`
verifies that the array it counts is the one its key was cached for. Two literal
membership chains in the tabular renderer and two in the list tokens read the
shared sets instead. `isWideChar`'s zero-width test no longer runs twice per code
point. `render_item_inline` treats both list kinds alike.
The `20em` clamp no longer warns. It fired on valid input and, as the README had to
say, meant nothing a consumer could act on: a level whose ancestors already reserve
enough does not overlap. Removing it also removes the filter both list test files
carried to keep it out of CI.
Released as a minor by decision — recorded in the changelog with what strict semver
would have made major. Absolute timings dropped from the changelog: they move with
the shape of the input and the host, and the spec carries the method.
Fixtures pin both pairs that differ only by a trailing paragraph (`$` and `\[`), a
raw-HTML link label the export carries through, and the orphan `<li>` an `\item`
after `\end{itemize}` leaves; the fixture sweep now also rejects an `<li>` outside
any list. Fuzzing 12000 documents over the changed paths: no invariant violated,
where `master` violates on 42.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two groups of renderer fixes, shipped together as one
3.0.2release:Lists (1–4): four unrelated inputs made LaTeX
itemize/enumeratelists render incorrectly; the fixes live in the list-env and footnote block rules.Code blocks (5): code-text styles were absolute (
px/rem), so a code block did not scale when a consumer sizes a rendered block by a singlefont-size; the styles are now relative.Version bump: 3.0.1 → 3.0.2
Specs:
pr-specs/2026-07-list-rendering-robustness.md,pr-specs/2026-07-code-block-font-scaling.mdChangelog:
doc/changelog.mdFull test suite green.
1. Marker padding for block-content items
A top-level list gets its
padding-inline-startfrom the widest custom\item[...]marker, but the width was only measured on the inline item path. Items whose content is a block environment (\begin{figure},\begin{tabular}, a code fence) were skipped, so a list whose long-marker items all hold block content lost its padding.MMD example
2. Marker width: fullwidth/CJK, math, and wrapped content
Marker width summed
String.lengthover top-leveltexttokens only, so several markers were undercounted and the list got too small an indent (marker overlaps the content):\item[11.], U+FF0E) counted as narrow ASCII — now East-Asian Wide/Fullwidth chars count as 2.\item[$x^4+x^4$]) contributed 0 — now uses the token's renderedwidthEx.\item[\textbf{…}]) contributed 0 — now measured through the wrapper's children.ASCII text markers are unchanged.
MMD examples
11.$x^4+x^4$\textbf{…}3. No env-state leak from an aborted list parse
The list block rule parses speculatively into a buffered state that shares
envby prototype. On abort (unclosed list) or a silent probe it returned without restoringenv.isBlock(andenv.inheritedListType); the leakedisBlock = truethen let the inline list fallback fire on the following content, so an uncloseditemizebefore atabularrendered a broken partial list with empty<>item bodies. Those transient fields are now restored on both paths, so the unclosed list degrades to plain text — exactly as it does without atabular.MMD example
4. Footnote block rules stop at a list start
The
\footnote/\footnotetextblock rules scan forward for their open tag, terminating at block boundaries so they don't swallow following blocks. The LaTeX list rule was not a terminator, so a\begin{itemize}between a paragraph and a later footnote (no blank line) did not stop the scan: the list was swallowed and rendered as literal text (a blank line masked the bug). The LaTeX list rule is now a terminator for both —\footnotetextvia its full set,\footnotevia a minimalfence+Lists(keeping its cheap scan).This is also a performance fix: on repeated paragraph + list-with-footnotetext units without blank separators the missing terminator made the scan run across every list into the rest of the document — O(N²), seconds on large inputs; terminating at the list makes it linear (guarded by a scaling test).
MMD example
5. Code-block styles scale with the em context
Code-text styles were pinned to absolute values, so when a consumer scales a rendered block by setting a single
font-sizeon the container (e.g. image export), everything scaled except the code —pxis fixed andremresolves against the root, not the block'sem. The four properties are now relative, calibrated so a 16px base is pixel-identical to before (only code padding moves 16px → 15px):#setText pre { font-size: 0.9375em; }(was85%)#setText pre code { font-size: inherit; }(was15px)#setText pre code { line-height: 1.6; }(was24px)#setText pre code { padding: 1em; }(was1rem)Styles only — no change to
lstlisting/ fenced-code markup.MMD example
Scale the rendered block by setting a large
font-sizeon the container (as image export does).Testing
List cases in
tests/_data/_lists/_data.jsandtests/_list-marker-padding.js: block-content markers (figure/fence), fullwidth11., math and\textbfmarkers, an unclosed list +tabular, a list after a paragraph with a multiline\footnote{}/\footnotetext{}, and a markdown list not swallowed before a\footnote.Guards: a scaling test (
tests/_footnotes_latex.js) rejecting the O(N²) footnote scan; a silent-Listsenv invariant (tests/_parse-isolation.js); selector-scoped code-style assertions (tests/_styles.js).Full suite green.
Non-goals
> 3threshold) is unchanged.code,prescroll/overflow, highlight colors, and table-cell padding are untouched.