Re-encode zero-width/format characters as entities in the qmd writer; accept them raw in prose - #677
Merged
Merged
Conversation
…cept them raw in prose (GH #672) Since the named-entity decode (PR #488) the reader folds `​` into a `Str` holding the literal U+200B, but the qmd writer emitted that codepoint verbatim and the grammar's prose regexes accepted no `Cf` character except ZWNJ/ZWJ, so pampa's own output failed to re-parse. Inside a heading superscript (quarto-web's `Welcome to Quarto^​…^`) the failure cascaded into Q-2-16 Unclosed Superscript. Two-sided fix: * Writer: `escape_markdown` now spells every Unicode format character (category `Cf`) as a character reference — the preferred WHATWG name where one exists (`​`, `­`, `‎`, `‏`, `⁠`, `⁡`, `⁢`, `⁣`), else `&#xXXXX;`. ZWNJ/ZWJ stay raw: they already parse, and ZWJ is structural inside emoji sequences. The `Cf` range table and preferred names live in the new `writers::format_chars`, pinned by tests against the `regex` crate's `\p{Cf}` and against the shared entity table. * Grammar: `PANDOC_COMBINING_MARKS` widens from `\p{M}\u{200C}\u{200D}` to `\p{M}\p{Cf}` (a strict superset), so raw zero-width characters — pasted from the web or written by older pampa builds — parse as verbatim `Str`, matching Pandoc. Raw format characters are therefore accepted but not canonical: they re-emit as references. Tests (all written and verified failing first): 7 corpus cases in `test/corpus/format_characters.txt`, 8 parser coverage tests, 5 writer unit tests, 4 pin tests, and three round-trip fixtures (`named_entities.qmd` extended; `format_characters_raw.qmd` and `zero_width_in_heading_superscript.qmd` new). No existing test or snapshot changed. `parser.c` regenerated (+496/−489 lines, character class rows only). Plan: claude-notes/plans/2026-09-11-zero-width-chars-writer-parser-gh672.md Braid: bd-wuiu1of7 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMPGiXmtk1yMv5YVVPPT9H
`escape_markdown` never escaped `&`, so a literal `Str "©"` (from `\©` in source) was written back as `©` and re-read as `©` — an AST-changing round trip. With the writer now emitting character references itself (caa29d6), a literal `Str "​"` and a `Str "\u{200B}"` would have produced identical bytes. The new `starts_character_reference` mirrors the grammar's two tokens — `numeric_character_reference` (`&#[0-9]{1,7};` / `&#[xX][0-9a-fA-F]{1,6};`) and `entity_reference` (semicolon-terminated WHATWG names, looked up in the reader's own `entity_table()` so both sides share one source of truth) — and emits `\&` only for those; `AT&T`, `a & b`, `&` and `&AM;` stay as they are. Pandoc's markdown writer escapes the same way. Tests written and verified failing first: 4 writer unit tests and the `ampersand_escaped_entities.qmd` round-trip fixture. No existing test or snapshot changed. Braid: bd-i18zoy4n (folded into bd-wuiu1of7's PR by decision 2026-09-11) Plan: claude-notes/plans/2026-09-11-zero-width-chars-writer-parser-gh672.md Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMPGiXmtk1yMv5YVVPPT9H
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMPGiXmtk1yMv5YVVPPT9H
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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.
Fixes #672.
Since the named-entity decode (#488) the reader folds
​into aStrholding the literal U+200B, but the qmd writer emitted that codepoint verbatim and the grammar's prose regexes accepted noCfcharacter except ZWNJ/ZWJ, so pampa's own output failed to re-parse. Inside a heading superscript (quarto-web's# Welcome to Quarto^​[®]{.trademark}^) the failure cascaded into Q-2-16 Unclosed Superscript.What changes
Writer (
caa29d6c):escape_markdownspells every Unicode format character (general categoryCf) as a character reference — the preferred WHATWG name where one exists (​,­,‎,‏,⁠,⁡,⁢,⁣), else&#xXXXX;. ZWNJ/ZWJ stay raw: they already parse, they are ordinary content in Persian/Indic text, and ZWJ is structural inside emoji sequences. TheCfrange table and the preferred names live in the newwriters::format_chars, with a comment linking the WHATWG table and the alias rule, pinned by tests against theregexcrate's\p{Cf}and against the shared entity table.Grammar (
caa29d6c):PANDOC_COMBINING_MARKSwidens from\p{M}\u{200C}\u{200D}to\p{M}\p{Cf}(a strict superset), so raw zero-width characters — pasted from the web, or written by older pampa builds — parse as verbatimStr, matching Pandoc. Raw format characters are therefore accepted but not canonical at the text level: they re-emit as references. (Pandoc's readers behave the same way:⁡,⁡and a raw U+2061 all become oneStr, so the source spelling is not recoverable and the writer emits the preferred name for all three.)&escaping (b15868ed, bd-i18zoy4n, folded in): the writer never escaped&, so a literalStr "©"(from\©) re-read as©. With the writer now emitting references itself this became load-bearing.starts_character_referencemirrors the grammar's two tokens (numeric refs by regex shape; named refs via the reader's ownentity_table()) and emits\&only for those —AT&T,a & b,&,&AM;are untouched. Pandoc's markdown writer escapes the same way.Tests
All written and verified failing first: 7 corpus cases (
test/corpus/format_characters.txt), 8 parser coverage tests, 9 writer unit tests, 4 pin tests, and four round-trip fixtures (named_entities.qmdextended;format_characters_raw.qmd,zero_width_in_heading_superscript.qmd,ampersand_escaped_entities.qmdnew). No existing test or snapshot changed.parser.cregenerated (+496/−489 lines, character-class rows only; generate took 0.26 s).Full
cargo xtask verify(including the hub-client/WASM legs) green for both code commits.End-to-end
Follow-up
Str) instead of erroring; Pandoc strips it, and so should we.Plan:
claude-notes/plans/2026-09-11-zero-width-chars-writer-parser-gh672.md. Braid: bd-wuiu1of7, bd-i18zoy4n.🤖 Generated with Claude Code
https://claude.ai/code/session_01EMPGiXmtk1yMv5YVVPPT9H