Skip to content

Survive replay contexts and boxed comments - #23

Open
phucnht wants to merge 1 commit into
developfrom
feat/robust-authoring
Open

Survive replay contexts and boxed comments#23
phucnht wants to merge 1 commit into
developfrom
feat/robust-authoring

Conversation

@phucnht

@phucnht phucnht commented Aug 25, 2026

Copy link
Copy Markdown
Owner

First slice of the roadmap's "Robust and accessible authoring" group, and the current Limitations section: markup in headings, captions, footnotes, floats and math.

Started by writing a failure matrix, not by guessing where the package might struggle: 16 probe documents, one context each, compiled against the unpatched package. Three failed outright and one (headings) silently miscounted and mis-bookmarked without erroring.

What was actually broken, measured before any fix

  • Duplicate-ID error on second pass. A change with an id inside a heading, caption, or anything else LaTeX reads twice (contents, running headers) is a protected command, so its tokens replay unexecuted and re-validate as a second occurrence of the same ID. Fatal.
  • Over-counting. One heading change on a two-page document produced 4 report lines for it (1 real + 1 TOC echo + 2 header echoes), confirmed by counting \txreportline occurrences before and after.
  • Garbled bookmarks. hyperref has no definition for \txreplace during PDF-string expansion, drops the command, keeps every argument: a heading bookmark read OLDHNEWH concatenated. Read directly out of the PDF's /Title bytes, not eyeballed.
  • "Float(s) lost". \marginpar is illegal inside a float, footnote, or minipage. A margin or todo comment there aborted the document. Reproduced in all three contexts.

The fix

A replay-depth counter, incremented/decremented by kernel hooks on \@starttoc and \@outputpage (verified these fire with a throwaway \typeout probe before relying on them). Depth rather than a boolean, because a page break inside a long contents listing nests one replay context inside another. Inside a replay: skip ID validation, skip counting and the report line, render resolved text instead of markup.

hyperref gets expandable stand-ins \let-remapped onto the public command names inside its own disable list, rather than trying to feed it macros with parameters (that's a hash-doubling trap in \pdfstringdefDisableCommands).

The margin fallback checks four measured signals rather than guessing: a kernel-hook flag for footnotes/floats, \@captype (exists only in a float), \@mpfn (reads mpfootnote only in a minipage), \mode_if_inner: for a plain restricted box. Falls back to an inline comment, warns once per document.

Report text: scanned for tokens that only work in the context they were written in (math shift, alignment, sub/superscript), shown as literal source only when one is found. Unconditional detokenizing was tried first and measured to mangle UTF-8 prose under pdfTeX (accented chars are active, arrive as raw bytes) — reverted once that showed up in a probe.

Verification

Every guard proven non-vacuous by disabling it and watching case_robust fail the specific way it should, then restoring:

  • no replay hooks → duplicate-ID error returns
  • no margin-context detection → "Float(s) lost" returns
  • no report-text scan → "Missing $ inserted" returns

All 16 original probes recompile clean. Full suite green at 23 cases including the new robust case and l3build.

A change command is protected, so its tokens travel unexecuted into
anything LaTeX reads twice: a table of contents, this package's own
report file, the running header and footer. Every one of those replays
was measured to fail, not assumed:

- an id inside a heading was a fatal duplicate-ID error on the second
  pass, because validation ran again for the replayed copy
- one change recorded once per echo in the change report: a heading
  with one change and a two-page document produced four report lines
  for it, not one
- a heading change's hyperref bookmark carried the raw command tokens
  concatenated, "OLDHNEWH", because hyperref's string expansion has no
  definition for \txreplace and drops the command while keeping every
  argument
- a margin or todo comment inside a float, a footnote, or a minipage
  was a fatal "Float(s) lost", because \marginpar is illegal in a box

The fix is a replay depth counter, incremented and decremented by kernel
hooks on \@starttoc and \@outputpage, so a change command renders its
resolved text instead of markup wherever it replays and skips validation
and counting entirely there. Depth rather than a flag, because a page
can break inside a long contents listing and nest one replay context
inside the other. hyperref gets its own expandable stand-ins, since
\pdfstringdefDisableCommands runs the disable list itself rather than
executing the document's, and the disable list only \let-remaps onto
them, avoiding the hash-doubling that macro parameters inside that list
would cause.

The margin fallback measures four signals against the actual kernel
contexts rather than guessing: \l_tx_boxed_bool set by the same kernel
hooks around \@makefntext and \@floatboxreset, \@CapType existing only
inside a float environment, \@MPFN reading mpfootnote only inside a
minipage, and \mode_if_inner: for a plain restricted box. Falls back to
an inline comment with a warning that fires once per document.

Report text is scanned for tokens that only work in the context they
were written in (math shift, alignment, sub/superscript) and shown as
literal source only when one is present; detokenizing unconditionally
was tried first and mangles UTF-8 prose under pdfTeX, where accented
characters are active and arrive as raw bytes.

tests/robust.tex exercises all of it together: a heading with an id, a
rejected heading change, inline and display math, a float with a
removed span and a boxed comment, a caption, a running header across a
page break, and a galley-mode comment that must remain a real margin
note. case_robust in scripts/test.sh pins one assertion per finding
above.

Verified each guard is not vacuous by disabling it in turn: no replay
hooks brings back the duplicate-ID failure, no margin-context detection
brings back "Float(s) lost", no report-text scan brings back "Missing $
inserted". Restoring each passes again. Full suite green at 23 cases.
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