Skip to content

[v3.0.2] List rendering robustness fixes (itemize / enumerate) - #422

Draft
OlgaRedozubova wants to merge 9 commits into
masterfrom
dev/olga/list-padding-block-item-marker
Draft

[v3.0.2] List rendering robustness fixes (itemize / enumerate)#422
OlgaRedozubova wants to merge 9 commits into
masterfrom
dev/olga/list-padding-block-item-marker

Conversation

@OlgaRedozubova

@OlgaRedozubova OlgaRedozubova commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Two groups of renderer fixes, shipped together as one 3.0.2 release:

  • Lists (1–4): four unrelated inputs made LaTeX itemize / enumerate lists 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 single font-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.md

  • Changelog: doc/changelog.md

  • Full test suite green.


1. Marker padding for block-content items

A top-level list gets its padding-inline-start from 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

\begin{itemize}
\item[11.33] In Fig. 11-20, when pump YA delivers 5.00 cfs.
\begin{figure}
\includegraphics[alt={},max width=\textwidth]{https://cdn.mathpix.com/snip/images/QxUdugRlfaW7h_IxvYNbgRDXjd0egDGrEXiSjKH7FGU.original.fullsize.png}
\caption{Fig. 11-20}
\end{figure}
\item[11.34] In Fig. 11-21, which system has the greater capacity.
\begin{figure}
\includegraphics[alt={},max width=\textwidth]{https://cdn.mathpix.com/snip/images/QxUdugRlfaW7h_IxvYNbgRDXjd0egDGrEXiSjKH7FGU.original.fullsize.png}
\caption{Fig. 11-21}
\end{figure}
\end{itemize}
Before After
Screenshot 2026-07-28 at 18 32 50 Screenshot 2026-07-28 at 18 32 18

2. Marker width: fullwidth/CJK, math, and wrapped content

Marker width summed String.length over top-level text tokens only, so several markers were undercounted and the list got too small an indent (marker overlaps the content):

  • Fullwidth / CJK (\item[11.], U+FF0E) counted as narrow ASCII — now East-Asian Wide/Fullwidth chars count as 2.
  • Math (\item[$x^4+x^4$]) contributed 0 — now uses the token's rendered widthEx.
  • Wrapped content (\item[\textbf{…}]) contributed 0 — now measured through the wrapper's children.

ASCII text markers are unchanged.

MMD examples

\begin{itemize}
\item[I] Item 1.
\item[11.] Item 11..
\item[I] Item 1.
\end{itemize}
\begin{itemize}
\item[$x^4 + x^4$] a footnote note or item body
\end{itemize}
\begin{itemize}
\item[\textbf{x^4 + x^4}] a footnote note or item body
\end{itemize}
Case Before After
fullwidth 11. Screenshot 2026-07-28 at 18 09 56 Screenshot 2026-07-28 at 18 10 13
math $x^4+x^4$ Screenshot 2026-07-28 at 18 27 20 Screenshot 2026-07-28 at 18 27 11
bold \textbf{…} Screenshot 2026-07-28 at 18 29 21 Screenshot 2026-07-28 at 18 29 06

3. No env-state leak from an aborted list parse

The list block rule parses speculatively into a buffered state that shares env by prototype. On abort (unclosed list) or a silent probe it returned without restoring env.isBlock (and env.inheritedListType); the leaked isBlock = true then let the inline list fallback fire on the following content, so an unclosed itemize before a tabular rendered 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 a tabular.

MMD example

\begin{itemize}
\item[(d1)] Asad Ali Khan
\item[(d2)] Asad Ali Khan
\begin{tabular}{|l|l|}
cell
\end{tabular}
\begin{itemize}
Before After
Screenshot 2026-07-28 at 18 34 13 Screenshot 2026-07-28 at 18 34 27

4. Footnote block rules stop at a list start

The \footnote / \footnotetext block 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 — \footnotetext via its full set, \footnote via a minimal fence + 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

Wiener's life work, its enormous range notwithstanding, exhibits a coherence of thought from start to finish, reminiscent of a great work of art. To write a biography of a person of so towering a stature is an
\begin{itemize}
\item[] \footnotetext{
1 Fortunately, this correspondence is now being studied by Dr. Albert C. Lewis of the Bertrand Russell Editorial Project, McMaster University, Ontario, Canada, and may see the light of day within a few years.
}
\end{itemize}
Before After
Screenshot 2026-07-28 at 18 35 45 Screenshot 2026-07-28 at 18 35 55

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-size on the container (e.g. image export), everything scaled except the code — px is fixed and rem resolves against the root, not the block's em. 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; } (was 85%)
  • #setText pre code { font-size: inherit; } (was 15px)
  • #setText pre code { line-height: 1.6; } (was 24px)
  • #setText pre code { padding: 1em; } (was 1rem)

Styles only — no change to lstlisting / fenced-code markup.

MMD example

\begin{lstlisting}
def area(r):
    return 3.14159 * r * r
\end{lstlisting}

Scale the rendered block by setting a large font-size on the container (as image export does).

Before After
Screenshot 2026-07-28 at 18 37 00 Screenshot 2026-07-28 at 18 37 13

Testing

List cases in tests/_data/_lists/_data.js and tests/_list-marker-padding.js: block-content markers (figure/fence), fullwidth 11., math and \textbf markers, 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-Lists env invariant (tests/_parse-isolation.js); selector-scoped code-style assertions (tests/_styles.js).

Full suite green.

Non-goals

  • The list padding heuristic itself (px-per-char, the > 3 threshold) is unchanged.
  • Malformed list input still degrades gracefully; the goal is plain text, not a partial list.
  • Nested lists keep the default indent even with long markers (pre-existing; separate ticket).
  • Code blocks: rendering at the default 16px base is unchanged apart from the 1px code padding; inline code, pre scroll/overflow, highlight colors, and table-cell padding are untouched.

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.
@OlgaRedozubova OlgaRedozubova self-assigned this Jul 28, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant