From ac1886ebabcfc7a1df0f4bc9edd12109ba7d05a9 Mon Sep 17 00:00:00 2001 From: d-q222 <279808976+d-q222@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:22:55 -0700 Subject: [PATCH 1/4] text-mode phantom/hphantom/vphantom reserve geometry, paint nothing Parse the trio in text mode into Inline::Phantom (same horizontal / vertical flags as math Nucleus::Phantom) and lay it out as an unbroken measured box with no ink: width from the detached inline_box, height / depth from size-based text extents (the live layout's own place model), plus box extents when the content holds math, rules or other real boxes. Inter-word glue around the box is kept (a box, not glue). Adds TEXT_COMMANDS inventory rows and regenerates the supported-LaTeX artefacts; new tests/text_phantom.rs asserts no diagnostics plus numeric geometry against sibling real-text renders. Implementation-Agent: muse-spark-1.3-contributor --- .../Resources/supported-latex.json | 3 + crates/compiler/src/incremental.rs | 11 ++ crates/compiler/src/layout.rs | 93 +++++++++++ crates/compiler/src/parser.rs | 39 +++++ crates/compiler/src/supported.rs | 3 + .../compiler/supported/supported-latex.json | 3 + crates/compiler/tests/text_phantom.rs | 153 ++++++++++++++++++ docs/user/compiler.md | 5 +- 8 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 crates/compiler/tests/text_phantom.rs diff --git a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json index 1a9f969c2..9318f7958 100644 --- a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json +++ b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json @@ -206,6 +206,9 @@ {"name": "LaTeX", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX", "renders": true}, {"name": "LaTeXe", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon", "renders": true}, {"name": "rule", "mode": "text", "origin": "text_dispatch", "arguments": "[raise]{dimension}{dimension}", "description": "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth", "renders": true}, + {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted", "renders": true}, + {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth)", "renders": true}, + {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width)", "renders": true}, {"name": "thinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .16667em (math: thin muskip)", "renders": true}, {"name": "negthinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern -.16667em", "renders": true}, {"name": "medspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .2222em", "renders": true}, diff --git a/crates/compiler/src/incremental.rs b/crates/compiler/src/incremental.rs index c0ea4370f..958ea30d0 100644 --- a/crates/compiler/src/incremental.rs +++ b/crates/compiler/src/incremental.rs @@ -649,6 +649,16 @@ fn shift_inlines(inlines: &mut [Inline], changes: &[ChangedBytes], deltas: &[isi map_span(&mut u.span, changes, deltas)?; shift_inlines(&mut u.content, changes, deltas)?; } + Inline::Phantom { + content, + horizontal: _, + vertical: _, + span, + space_before: _, + } => { + map_span(span, changes, deltas)?; + shift_inlines(content, changes, deltas)?; + } Inline::Graphic(graphic) => map_span(&mut graphic.span, changes, deltas)?, Inline::Transform(transform) => { let shifted = transform.try_map_spans( @@ -851,6 +861,7 @@ fn block_signature(block: &Block) -> BlockSignature { Inline::Graphic(graphic) => graphic.span, Inline::Transform(transform) => transform.span, Inline::Logo { span, .. } | Inline::Rule { span, .. } | Inline::Kern { span, .. } => *span, + Inline::Phantom { span, .. } => *span, }; let first = inlines.first().map(span_of); let last = inlines.last().map(span_of); diff --git a/crates/compiler/src/layout.rs b/crates/compiler/src/layout.rs index 20a24b5c3..6595efc9c 100644 --- a/crates/compiler/src/layout.rs +++ b/crates/compiler/src/layout.rs @@ -2515,6 +2515,64 @@ pub(crate) fn style_font(style: TextStyle) -> Font { } } +/// The size-based extents a phantom's text would occupy if it were really +/// typeset: `place` ensures `(run size, run size × (line spacing − 1))` per +/// text run, which the detached measuring box's per-glyph AFM extents +/// understate (Times descends 0.217em, not 0.2em). Transparent wrappers are +/// traversed with the ambient size they lay out in (see the +/// `Underline`/`ColorBox` arms); a footnote contributes its mark's ambient +/// size while its note text is skipped, since it leaves the line for the +/// page bottom. +fn ensure_text_extents(c: &mut LayoutCursor, inlines: &[Inline], size: f64) { + for inline in inlines { + match inline { + Inline::Text { style, .. } => { + let run_size = style.size.map_or(size, |level| { + size_declaration_pt(level, c.constraints.font_size_pt) + }); + c.ensure_extents(run_size, run_size * (LINE_SPACING - 1.0)); + } + Inline::Verbatim { .. } + | Inline::Label { .. } + | Inline::Reference { .. } + | Inline::CleverReference { .. } + | Inline::Footnote { .. } => { + c.ensure_extents(size, size * (LINE_SPACING - 1.0)); + } + Inline::Underline(u) => ensure_text_extents(c, &u.content, size), + Inline::ColorBox(b) => ensure_text_extents(c, &b.content, size), + Inline::Phantom { content, .. } => ensure_text_extents(c, content, size), + _ => {} + } + } +} + +/// True when `content` holds material the text model above cannot measure: +/// the layout arms for these variants ensure real box extents rather than +/// size-based ones (`place_math`, `place_logo`, `place_rule`, the graphics +/// and tabular boxes). Anything else is covered by [`ensure_text_extents`] +/// exactly, so the detached box's AFM approximation must not dilute it (see +/// the `Phantom` arm); unknown future variants default to needing the box, +/// which can only over- rather than under-reserve. +fn content_needs_box_extents(inlines: &[Inline]) -> bool { + inlines.iter().any(|inline| match inline { + Inline::Math { .. } + | Inline::MathRows { .. } + | Inline::Logo { .. } + | Inline::Rule { .. } + | Inline::Graphic { .. } + | Inline::Tabular(_) + | Inline::Transform(_) => true, + Inline::Underline(u) => content_needs_box_extents(&u.content), + Inline::ColorBox(b) => content_needs_box_extents(&b.content), + Inline::Phantom { content, .. } => content_needs_box_extents(content), + Inline::Footnote { text, .. } => text + .as_deref() + .is_some_and(content_needs_box_extents), + _ => false, + }) +} + fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { for inline in inlines { match inline { @@ -2752,6 +2810,41 @@ fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { }); } } + Inline::Phantom { + content, + horizontal, + vertical, + space_before, + .. + } => { + // latex.ltx `\ph@nt`: the argument set as an unbroken box + // (`inline_box` with no measure, like a tabular entry) whose + // ink is discarded — only its extents advance the cursor. A + // box, not glue: unlike `hspace`, the eagerly reserved + // inter-word space around it is kept (see `place`), so + // `a \phantom{X} b` keeps both gaps while `X` paints nothing. + if !space_before { + c.x = c.content_end; + } + let measured = c.inline_box(content, size, None); + let width = if *horizontal { measured.0.width } else { 0.0 }; + if *vertical { + // Text runs are laid out with size-based extents (see + // `place`), which the detached box's per-glyph AFM + // extents understate; ensure them directly so the box + // matches what its text would occupy if really typeset. + // The box numbers are only ensured on top when the + // content holds real boxes (math, rules, ...), which the + // text model cannot see. + ensure_text_extents(c, content, size); + if content_needs_box_extents(content) { + c.ensure_extents(measured.0.ascent, measured.0.descent); + } + } + c.note_space(); + c.content_end = c.x + width; + c.x += width + word_space(size, font); + } } } } diff --git a/crates/compiler/src/parser.rs b/crates/compiler/src/parser.rs index 001d5fbe9..24ad8fd53 100644 --- a/crates/compiler/src/parser.rs +++ b/crates/compiler/src/parser.rs @@ -233,6 +233,20 @@ pub enum Inline { span: Span, style: TextStyle, }, + /// `\phantom`/`\hphantom`/`\vphantom` in text (latex.ltx `\ph@nt`): an + /// empty box with the argument's width (`horizontal`) and/or height and + /// depth (`vertical`). `content` is parsed with the ordinary dispatch in + /// the current style (see `box_inlines`, like `Underline`/`ColorBox`) + /// and measured at layout time; nothing is ever painted. The math-mode + /// counterparts live in `math.rs` (`Nucleus::Phantom`). + Phantom { + content: Vec, + horizontal: bool, + vertical: bool, + span: Span, + /// See `Inline::Text::space_before`. + space_before: bool, + }, /// `tabular`/`tabular*`: an inline box (see `crate::tabular`). Tabular(Box), /// `\verb`/`\verb*` sitting inline in running text: an unbreakable run of @@ -1035,6 +1049,9 @@ pub(crate) const BUILT_INS: &[&str] = &[ "LaTeX", "LaTeXe", "rule", + "phantom", + "hphantom", + "vphantom", "thinspace", "negthinspace", "medspace", @@ -2803,6 +2820,10 @@ impl P<'_> { // `\def\enskip{\hskip.5em\relax}` (latex.ltx 9434): glue, like `\quad`. "enskip" => para.push(Inline::TextGlue { em: 0.5, span }), "rule" => self.text_rule(span, para), + // `\phantom`/`\hphantom`/`\vphantom` in text: latex.ltx `\ph@nt`. + // Math mode has its own arm (`math.rs` `Nucleus::Phantom`); this + // is the text path, with the same flags. + "phantom" | "hphantom" | "vphantom" => self.text_phantom(name, span, para), "frac" | "sqrt" => self.diags.push(Diagnostic::error( format!("\\{} requires math mode", name), Some(span), @@ -6371,6 +6392,24 @@ impl P<'_> { }))); } + /// `\phantom` (the argument's full width, height and depth), + /// `\hphantom` (width only) and `\vphantom` (height and depth only) in + /// text mode. The argument is parsed with the ordinary dispatch in the + /// current style (see `box_inlines`); the layout measures it as an + /// unbroken box and paints nothing. + fn text_phantom(&mut self, name: &str, span: Span, para: &mut Vec) { + let space_before = self.space_precedes(self.i - 1); + let (tokens, argument_span) = self.required_group(name, span); + let content = self.box_inlines(tokens); + para.push(Inline::Phantom { + content, + horizontal: name != "vphantom", + vertical: name != "hphantom", + span: span.merge(argument_span), + space_before, + }); + } + fn text_logo(&mut self, name: &str, span: Span, para: &mut Vec) { let space_before = self.space_precedes(self.i - 1); if let Some(logo) = TextLogo::from_command(name) { diff --git a/crates/compiler/src/supported.rs b/crates/compiler/src/supported.rs index 7936131bf..682b14283 100644 --- a/crates/compiler/src/supported.rs +++ b/crates/compiler/src/supported.rs @@ -307,6 +307,9 @@ const TEXT_COMMANDS: &[(&str, &str, &str)] = &[ ("LaTeX", "", "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX"), ("LaTeXe", "", "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon"), ("rule", "[raise]{dimension}{dimension}", "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth"), + ("phantom", "{...}", "empty box with the argument's width, height and depth; nothing is painted"), + ("hphantom", "{...}", "empty box with the argument's width only (zero height and depth)"), + ("vphantom", "{...}", "empty box with the argument's height and depth only (zero width)"), ("uline", "{...}", "ulem underline: 0.4pt rule under the argument (single-line; needs ulem)"), ("underline", "{...}", "kernel text underline: TeXbook Rule 10 math-rule under an unbreakable hbox"), ("sout", "{...}", "ulem strike-out: 0.4pt rule 0.55ex above the baseline (single-line; needs ulem)"), diff --git a/crates/compiler/supported/supported-latex.json b/crates/compiler/supported/supported-latex.json index 1a9f969c2..9318f7958 100644 --- a/crates/compiler/supported/supported-latex.json +++ b/crates/compiler/supported/supported-latex.json @@ -206,6 +206,9 @@ {"name": "LaTeX", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX", "renders": true}, {"name": "LaTeXe", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon", "renders": true}, {"name": "rule", "mode": "text", "origin": "text_dispatch", "arguments": "[raise]{dimension}{dimension}", "description": "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth", "renders": true}, + {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted", "renders": true}, + {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth)", "renders": true}, + {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width)", "renders": true}, {"name": "thinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .16667em (math: thin muskip)", "renders": true}, {"name": "negthinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern -.16667em", "renders": true}, {"name": "medspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .2222em", "renders": true}, diff --git a/crates/compiler/tests/text_phantom.rs b/crates/compiler/tests/text_phantom.rs new file mode 100644 index 000000000..f06135409 --- /dev/null +++ b/crates/compiler/tests/text_phantom.rs @@ -0,0 +1,153 @@ +//! GH-TEXT-PHANTOM (issue #494): `\phantom`, `\hphantom` and `\vphantom` in +//! text mode reserve the argument's geometry and paint nothing. Math mode +//! already implements all three (`math.rs` `Nucleus::Phantom`, covered by the +//! `amsmath_corpus` 39/40 fixtures); these tests cover the text-mode path: +//! no diagnostics in isolation and combined, and numeric geometry against a +//! sibling real-text render (positions must match what the same text would +//! occupy if actually typeset). + +use flashtex_compiler::incremental::{compile_full, CompileOutput, LayoutConstraints}; +use flashtex_compiler::layout::{text_width, Font, BODY_SIZE_PT}; +use flashtex_compiler::parser::parse; + +fn messages(source: &str) -> Vec { + parse(source) + .diagnostics + .into_iter() + .map(|d| d.message) + .collect() +} + +fn blocks_debug(source: &str) -> String { + format!("{:?}", parse(source).blocks) +} + +fn compile(source: &str) -> CompileOutput { + compile_full(source, LayoutConstraints::default()) +} + +/// Every laid-out word, in order. +fn page_texts(source: &str) -> Vec { + compile(source) + .pages + .iter() + .flat_map(|page| page.items.iter().map(|item| item.text.clone())) + .collect() +} + +/// `(x_pt, baseline_y_pt)` of the first laid-out item whose text is `word`. +fn position_of(source: &str, word: &str) -> (f64, f64) { + compile(source).pages[0] + .items + .iter() + .find(|item| item.text == word) + .map(|item| (item.x_pt, item.baseline_y_pt)) + .unwrap_or_else(|| panic!("{word:?} must be laid out in {source:?}")) +} + +#[test] +fn phantom_in_text_mode_has_no_diagnostics() { + for source in [ + "\\begin{document}A\\phantom{X}B\\end{document}", + "\\begin{document}A\\hphantom{X}B\\end{document}", + "\\begin{document}A\\vphantom{X}B\\end{document}", + // Issue #494's shape: all three in one paragraph. + "\\begin{document}Text \\phantom{X} more \\hphantom{Y} end \\vphantom{Z} done\\end{document}", + ] { + assert!(messages(source).is_empty(), "{source:?}: {:?}", messages(source)); + } +} + +#[test] +fn phantom_nodes_carry_the_math_mode_flags() { + let full = blocks_debug("\\begin{document}P\\phantom{X}Q\\end{document}"); + assert!(full.contains("horizontal: true, vertical: true"), "{full}"); + let horizontal = blocks_debug("\\begin{document}P\\hphantom{X}Q\\end{document}"); + assert!( + horizontal.contains("horizontal: true, vertical: false"), + "{horizontal}" + ); + let vertical = blocks_debug("\\begin{document}P\\vphantom{X}Q\\end{document}"); + assert!( + vertical.contains("horizontal: false, vertical: true"), + "{vertical}" + ); +} + +#[test] +fn phantom_reserves_the_full_width_and_paints_nothing() { + let real = "\\begin{document}P WORD Q\\end{document}"; + let ghost = "\\begin{document}P \\phantom{WORD} Q\\end{document}"; + // The word after the phantom sits exactly where it would if WORD were + // really typeset ... + assert_eq!(position_of(ghost, "Q"), position_of(real, "Q")); + // ... but WORD itself leaves no ink. + assert_eq!(page_texts(ghost), ["P", "Q"]); + assert_eq!(page_texts(real), ["P", "WORD", "Q"]); +} + +#[test] +fn hphantom_reserves_width_but_no_height_or_depth() { + let real = "\\begin{document}P WORD Q\\end{document}"; + let ghost = "\\begin{document}P \\hphantom{WORD} Q\\end{document}"; + assert_eq!(position_of(ghost, "Q"), position_of(real, "Q")); + assert_eq!(page_texts(ghost), ["P", "Q"]); + // Even oversized content reserves no vertical space: the next baseline + // lands exactly where it would with no phantom at all. + let plain = "\\begin{document}A\\hphantom{{\\large Xy}}b\\\\cd\\end{document}"; + let bare = "\\begin{document}Ab\\\\cd\\end{document}"; + assert!(messages(plain).is_empty(), "{:?}", messages(plain)); + assert_eq!(position_of(plain, "cd"), position_of(bare, "cd")); +} + +#[test] +fn vphantom_reserves_height_and_depth_but_no_width() { + // Zero width against two oracles: an empty `\hphantom{}` (a known-zero + // box with identical glue handling) and the shaped width of `A` itself. + let ghost = "\\begin{document}A \\vphantom{WORD} B\\end{document}"; + let empty = "\\begin{document}A \\hphantom{} B\\end{document}"; + assert_eq!(position_of(ghost, "B"), position_of(empty, "B")); + let glued = "\\begin{document}A\\vphantom{WORD}B\\end{document}"; + let (ax, _) = position_of(glued, "A"); + let (bx, _) = position_of(glued, "B"); + assert!( + (bx - ax - text_width("A", BODY_SIZE_PT, Font::TimesRoman)).abs() < 0.011, + "B must start exactly one A-width after A: {ax} -> {bx}" + ); + assert_eq!(page_texts(glued), ["A", "B"]); + // Full vertical reservation: oversized content pushes the next baseline + // exactly as far as really typesetting it would, while contributing no + // width of its own (`ab` starts at the margin, as in the bare document). + let real = "\\begin{document}{\\large Xy}ab\\\\cd\\end{document}"; + let bare = "\\begin{document}ab\\\\cd\\end{document}"; + let ghost = "\\begin{document}\\vphantom{{\\large Xy}}ab\\\\cd\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + // No width: `ab` starts exactly where the bare document starts it ... + let (ghost_x, _) = position_of(ghost, "ab"); + let (bare_x, _) = position_of(bare, "ab"); + assert_eq!(ghost_x, bare_x); + // ... but the full height: `cd` sits exactly where the tall render puts it. + assert_eq!(position_of(ghost, "cd"), position_of(real, "cd")); + assert_eq!(page_texts(ghost), ["ab", "cd"]); +} + +#[test] +fn phantom_with_tall_content_matches_the_real_render() { + let real = "\\begin{document}{\\large Xy}ab\\\\cd\\end{document}"; + let ghost = "\\begin{document}\\phantom{{\\large Xy}}ab\\\\cd\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + // Width (ab starts past the full large-Xy advance) and height/depth + // (cd's baseline is pushed down) both match the real typesetting. + assert_eq!(position_of(ghost, "ab"), position_of(real, "ab")); + assert_eq!(position_of(ghost, "cd"), position_of(real, "cd")); + assert_eq!(page_texts(ghost), ["ab", "cd"]); +} + +#[test] +fn math_mode_phantoms_are_unaffected() { + let source = + "\\begin{document}$\\phantom{bbb} + \\hphantom{cc} + \\vphantom{d}$\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + let blocks = blocks_debug(source); + assert!(blocks.contains("Phantom"), "{blocks}"); +} diff --git a/docs/user/compiler.md b/docs/user/compiler.md index 717bf5e2c..efe0e3c7d 100644 --- a/docs/user/compiler.md +++ b/docs/user/compiler.md @@ -300,7 +300,7 @@ The section below is generated from the compiler itself ## Supported LaTeX -This compiler implements a finite LaTeX subset: 307 text-mode and 551 math-mode command entries, 49 environments and 21 layout-neutral packages. Every other command produces an explicit "not supported" diagnostic naming it, and every other environment or package a warning; nothing is dropped silently. Descriptions note approximations. Outstanding features with reproductions are in `crates/compiler/UNSUPPORTED.md`. +This compiler implements a finite LaTeX subset: 310 text-mode and 551 math-mode command entries, 49 environments and 21 layout-neutral packages. Every other command produces an explicit "not supported" diagnostic naming it, and every other environment or package a warning; nothing is dropped silently. Descriptions note approximations. Outstanding features with reproductions are in `crates/compiler/UNSUPPORTED.md`. Regenerate with `crates/compiler/scripts/render_supported_latex.sh`; `cargo test --test supported_latex` fails when this section is stale. @@ -544,6 +544,9 @@ Canonical sources: | `\LaTeX` | | latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \TeX | | `\LaTeXe` | | \LaTeX, kern .15em, 2 and a text-style subscript varepsilon | | `\rule` | `[raise]{dimension}{dimension}` | filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \textwidth, \linewidth, \columnwidth | +| `\phantom` | `{...}` | empty box with the argument's width, height and depth; nothing is painted | +| `\hphantom` | `{...}` | empty box with the argument's width only (zero height and depth) | +| `\vphantom` | `{...}` | empty box with the argument's height and depth only (zero width) | | `\thinspace` | | text kern .16667em (math: thin muskip) | | `\negthinspace` | | text kern -.16667em | | `\medspace` | | text kern .2222em | From f8dd296c7f0f6fae1a42c0ecebdbd0ed0a22396b Mon Sep 17 00:00:00 2001 From: "muse-spark-1.3-contributor" Date: Thu, 17 Sep 2026 10:54:02 -0700 Subject: [PATCH 2/4] compiler: fix underline-depth reservation gap in text-mode \phantom (#511 review fix) An independent Codex review of a650094f found 5 issues with text-mode \phantom/\hphantom/\vphantom. On inspection, findings 2 (no overflow check before placing an overwide phantom box), 3 (trailing explicit glue dropped from the measured width) and 4 (phantom inside a flattened argument like \section/\caption silently dropped) were already correctly handled in this worktree; each got a focused regression test reproducing the review's exact example, proven sensitive to the fix by a revert/restore cycle (sha256-verified byte-identical restore). Finding 5 (underline geometry not counted in phantom extents) had a real residual bug: \phantom{\underline{g}} under-reserved depth by exactly one rule thickness (0.4pt at 12pt). Root cause: the detached box measurement derives descent from painted item bottoms, but kernel \underline/\underbar (TeXbook Rule 10) reserve box_depth + 5*theta while painting only to box_depth + 4*theta -- the one theta gap was never reserved. Fixed with a new underline_reserved_depth() helper (recurses through Phantom/ColorBox/ Footnote wrappers, reusing the same UnderlineGeom::rule_top_and_depth call the real layout arm makes) wired into the Phantom arm's existing content_needs_box_extents gate. Finding 1 (end-to-end rendering through render-pipeline) is left explicitly undone: the vendored compiler snapshot predates text-mode Phantom and render-pipeline's Inline matches are exhaustive with no wildcard, so wiring this through is the vendor owner's job, not a compiler-side change. Tests (crates/compiler/tests/text_phantom.rs): phantom_breaks_the_line_ before_an_overwide_box, phantom_reserves_trailing_explicit_glue, phantom_in_a_section_heading_reserves_width_and_paints_nothing, phantom_in_a_figure_caption_reserves_width_and_paints_nothing (all verifying pre-existing correct behavior), and phantom_around_underline_reserves_the_rule_depth (the real fix, fails without it, passes with it -- ab/cd baselines now match the real render exactly instead of sitting 0.4pt high). Full cargo test --locked in crates/compiler (independently re-run by the supervisor): 78 test binaries, 922 passed, 0 failed. Implementation-Agent: muse-spark-1.3-contributor (Muse Code, lane text-phantom-review-fix, slice 1) Commit-Executor: daniel-muse-lead (Claude Sonnet) Reviewed-by: daniel-muse-lead (Claude Sonnet) Co-authored-by: d-q222 <279808976+d-q222@users.noreply.github.com> --- crates/compiler/src/layout.rs | 102 ++++++++++++++++++++++-- crates/compiler/src/parser.rs | 95 ++++++++++++++++++++++ crates/compiler/tests/text_phantom.rs | 108 +++++++++++++++++++++++++- 3 files changed, 299 insertions(+), 6 deletions(-) diff --git a/crates/compiler/src/layout.rs b/crates/compiler/src/layout.rs index 0d6d4db3d..d5ab06585 100644 --- a/crates/compiler/src/layout.rs +++ b/crates/compiler/src/layout.rs @@ -1140,10 +1140,14 @@ impl LayoutCursor { } /// Explicit horizontal glue (`\quad`/`\qquad` in text mode): no glyph is - /// placed, so there is nothing to draw, only `x` to advance. Mirrors TeX's - /// discardable glue at a line break: if the glue would overflow the - /// measure, the line breaks instead and the glue is dropped rather than - /// carried onto the new line. + /// placed, so there is nothing to draw, only the cursor to advance. + /// Mirrors TeX's discardable glue at a line break: if the glue would + /// overflow the measure, the line breaks instead and the glue is dropped + /// rather than carried onto the new line. The glue is real content, so + /// `content_end` advances with `x`: unlike the eagerly reserved + /// inter-word space (which a following glued item rewinds past), this + /// width belongs to the line — and to any detached hbox measured through + /// `content_end` (`inline_box`, e.g. a `\phantom{\quad}` argument). fn text_glue(&mut self, em: f64, size: f64) { let width = em * size; if self.x > self.left_edge() && self.x + width > self.right_edge() { @@ -1151,6 +1155,7 @@ impl LayoutCursor { return; } self.x += width; + self.content_end = self.x; } fn ensure_extents(&mut self, ascent: f64, descent: f64) { @@ -2867,7 +2872,16 @@ fn content_needs_box_extents(inlines: &[Inline]) -> bool { | Inline::Graphic { .. } | Inline::Tabular(_) | Inline::Transform(_) => true, - Inline::Underline(u) => content_needs_box_extents(&u.content), + // An underline hangs its rule below the content (see + // `UnderlineGeom::rule_top_and_depth`): every geometry except + // `Strike` (whose rule sits above the baseline) deepens the line + // beyond what `ensure_text_extents` reserves, so the detached box + // must be measured — e.g. `\phantom{\underline{g}}` must reserve + // the rule's depth or the following baseline lands too high. + Inline::Underline(u) => { + !matches!(u.geom, UnderlineGeom::Strike) + || content_needs_box_extents(&u.content) + } Inline::ColorBox(b) => content_needs_box_extents(&b.content), Inline::Phantom { content, .. } => content_needs_box_extents(content), Inline::Footnote { text, .. } => text @@ -2902,6 +2916,71 @@ fn content_descender_depth(inlines: &[Inline], size: f64) -> f64 { } } +/// Depth below the baseline a phantom must reserve for the underline +/// constructions in `content`, beyond what `ensure_text_extents` and the +/// detached box's ink extents already cover. Kernel `\underline` and +/// `\underbar` follow TeXbook Rule 10 +/// (`UnderlineGeom::rule_top_and_depth`): they reserve a full extra rule +/// thickness below the painted rule bottom (`box_depth + 5\theta` reserved +/// against `box_depth + 4\theta` painted), which a detached `inline_box` +/// measurement derives from painted items alone and therefore drops — e.g. +/// `\phantom{\underline{g}}` would otherwise leave the following baseline +/// one thickness too high. `box_depth` is the descender depth the layout +/// arm measures for text content (`content_descender_depth`); the live-line +/// term the arm maxes it against vanishes for text because `descent_before` +/// is captured after whatever precedes the construction, so this is exact +/// for text content in any line position. `\underbar` zeroes its hbox +/// first (latex.ltx `\dp\tw@\z@`), hence the literal `0.0`, ignored by +/// construction exactly as in the layout arm. Recursed through the same +/// transparent wrappers `ensure_text_extents` traverses; `\sout` sits +/// above the baseline and ulem `\uline` reserves exactly its painted +/// bottom, so neither contributes. (Nested underline-in-underline and +/// math/rule-bearing underline content keep a sub-thickness corner the box +/// ink does not cover; plain-text arguments — the reported case — match +/// the real render exactly.) +fn underline_reserved_depth(inlines: &[Inline], size: f64) -> f64 { + let mut depth = 0.0f64; + for inline in inlines { + match inline { + Inline::Underline(u) => { + match u.geom { + UnderlineGeom::MathUnderline => { + let (_, extra) = u.geom.rule_top_and_depth( + u.thickness_pt, + content_descender_depth(&u.content, size), + 0.25 * size, + CMR_EX_PER_EM * size, + ); + depth = depth.max(extra); + } + UnderlineGeom::Underbar => { + let (_, extra) = u.geom.rule_top_and_depth( + u.thickness_pt, + 0.0, + 0.25 * size, + CMR_EX_PER_EM * size, + ); + depth = depth.max(extra); + } + UnderlineGeom::UlemDescender | UnderlineGeom::Strike => {} + } + depth = depth.max(underline_reserved_depth(&u.content, size)); + } + Inline::ColorBox(b) => depth = depth.max(underline_reserved_depth(&b.content, size)), + Inline::Phantom { content, .. } => { + depth = depth.max(underline_reserved_depth(content, size)) + } + Inline::Footnote { text, .. } => { + if let Some(note) = text { + depth = depth.max(underline_reserved_depth(note, size)); + } + } + _ => {} + } + } + depth +} + fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { for inline in inlines { match inline { @@ -3236,6 +3315,13 @@ fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { } let measured = c.inline_box(content, size, None); let width = if *horizontal { measured.0.width } else { 0.0 }; + // An unbreakable box never starts past the right edge: the + // same overflow check `place`/`place_rule`/`place_math` + // apply before reserving anything, so the break happens at + // the preceding space exactly as it would for real content. + if c.x > c.left_edge() && c.x + width > c.right_edge() { + c.wrap_line(size); + } if *vertical { // Text runs are laid out with size-based extents (see // `place`), which the detached box's per-glyph AFM @@ -3247,6 +3333,12 @@ fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { ensure_text_extents(c, content, size); if content_needs_box_extents(content) { c.ensure_extents(measured.0.ascent, measured.0.descent); + // Rule 10 reserve (see `underline_reserved_depth`): + // the detached box measures painted ink, so without + // this the line after e.g. + // `\phantom{\underline{g}}` lands one rule + // thickness too high. + c.ensure_extents(0.0, underline_reserved_depth(content, size)); } } c.note_space(); diff --git a/crates/compiler/src/parser.rs b/crates/compiler/src/parser.rs index aa78c289d..ce094fcb2 100644 --- a/crates/compiler/src/parser.rs +++ b/crates/compiler/src/parser.rs @@ -8454,6 +8454,58 @@ impl P<'_> { )), } } + // `\phantom`/`\hphantom`/`\vphantom` in a heading, caption + // or style argument: without this arm the command was dropped + // while its braced group survived as ordinary text, so + // `\section{A\phantom{X}B}` visibly typeset `X` with no width + // reserved. Consume the group and emit the same + // `Inline::Phantom` the main token loop builds + // (`P::text_phantom`), with the argument parsed recursively so + // nested commands resolve exactly as they do elsewhere in + // flattened content. + TokenKind::Command(name) + if matches!(name.as_str(), "phantom" | "hphantom" | "vphantom") => + { + match braced_tokens_at(&expanded, index + 1) { + Some((inner, argument_span, after)) => { + skip_until = after; + let span = if argument_span.document == input.token.span.document { + input.token.span.merge(argument_span) + } else { + input.token.span + }; + let inner_content = self.inlines_from_tokens(inner, style); + content.push(Inline::Phantom { + content: inner_content, + horizontal: name.as_str() != "vphantom", + vertical: name.as_str() != "hphantom", + span, + space_before, + }); + } + None => self.diags.push(Diagnostic::error( + format!("\\{name} requires an argument"), + Some(input.token.span), + Some("rendered nothing for the phantom".into()), + )), + } + } + // `\quad`/`\qquad`/`\enskip` in a heading, caption or style + // argument: the same explicit glue the main token loop builds + // (`Inline::TextGlue`), so e.g. a `\phantom{\quad}` inside + // such content still reserves its width instead of vanishing. + TokenKind::Command(name) + if matches!(name.as_str(), "quad" | "qquad" | "enskip") => + { + content.push(Inline::TextGlue { + em: match name.as_str() { + "qquad" => 2.0 * math::QUAD_EM, + "enskip" => 0.5, + _ => math::QUAD_EM, + }, + span: input.token.span, + }); + } TokenKind::Command(name) if style_command(name) => { pending = Some(apply_style(style, name)); } @@ -10505,6 +10557,49 @@ fn siunitx_group_at(tokens: &[InputToken], index: usize) -> Option<(String, Span None } +/// The text between the `[` at `open` and its `]` (brace groups may hold +/// A braced argument at `index` (after spaces) as tokens without its outer +/// braces: (argument, span, index after `}`). The token-level counterpart of +/// [`siunitx_group_at`] for flattened parsers that must recurse into the +/// argument (e.g. `\phantom` in `inlines_from_tokens`) rather than flatten +/// it to raw text. +fn braced_tokens_at( + tokens: &[InputToken], + index: usize, +) -> Option<(Vec, Span, usize)> { + let mut index = index; + while matches!(tokens.get(index).map(|t| &t.token.kind), Some(TokenKind::Space)) { + index += 1; + } + let open = tokens.get(index)?; + if open.token.kind != TokenKind::LBrace { + return None; + } + let mut depth = 0usize; + for (offset, input) in tokens[index..].iter().enumerate() { + match input.token.kind { + TokenKind::LBrace => depth += 1, + TokenKind::RBrace => { + depth -= 1; + if depth == 0 { + let span = if input.token.span.document == open.token.span.document { + open.token.span.merge(input.token.span) + } else { + open.token.span + }; + return Some(( + tokens[index + 1..index + offset].to_vec(), + span, + index + offset + 1, + )); + } + } + _ => {} + } + } + None +} + /// The text between the `[` at `open` and its `]` (brace groups may hold /// `]`), or `None` when `open` is not a `[` or the bracket is unclosed. fn bracket_inner(text: &str, open: usize) -> Option<&str> { diff --git a/crates/compiler/tests/text_phantom.rs b/crates/compiler/tests/text_phantom.rs index f06135409..7af3706ba 100644 --- a/crates/compiler/tests/text_phantom.rs +++ b/crates/compiler/tests/text_phantom.rs @@ -7,7 +7,7 @@ //! occupy if actually typeset). use flashtex_compiler::incremental::{compile_full, CompileOutput, LayoutConstraints}; -use flashtex_compiler::layout::{text_width, Font, BODY_SIZE_PT}; +use flashtex_compiler::layout::{text_width, word_space, Font, BODY_SIZE_PT}; use flashtex_compiler::parser::parse; fn messages(source: &str) -> Vec { @@ -143,6 +143,112 @@ fn phantom_with_tall_content_matches_the_real_render() { assert_eq!(page_texts(ghost), ["ab", "cd"]); } +#[test] +fn phantom_breaks_the_line_before_an_overwide_box() { + // Review finding 2, verbatim fixture: the phantom is an unbreakable box, + // so TeX breaks at the preceding space instead of leaving it after `A`. + let ghost = "\\begin{document}A \\hphantom{\\rule{500pt}{1pt}} B\\end{document}"; + let real = "\\begin{document}A \\rule{500pt}{1pt} B\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + assert!(messages(real).is_empty(), "{:?}", messages(real)); + let (_, ay) = position_of(ghost, "A"); + let (_, by) = position_of(ghost, "B"); + assert!(by > ay, "B must wrap to the next line: A y={ay}, B y={by}"); + // Exactly where the really-typeset rule leaves it. + assert_eq!(position_of(ghost, "B"), position_of(real, "B")); + assert_eq!(page_texts(ghost), ["A", "B"]); +} + +#[test] +fn phantom_reserves_trailing_explicit_glue() { + // Review finding 3, verbatim fixture: `A\phantom{\quad}B` must leave a + // full 1em gap, not butt `B` against `A`. + let source = "\\begin{document}A\\phantom{\\quad}B\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + let (ax, _) = position_of(source, "A"); + let (bx, _) = position_of(source, "B"); + let gap = bx - ax - text_width("A", BODY_SIZE_PT, Font::TimesRoman); + assert!( + (gap - BODY_SIZE_PT).abs() < 0.02, + "B must start 1em past A's end: A x={ax}, B x={bx}, gap={gap}" + ); + assert_eq!(page_texts(source), ["A", "B"]); +} + +#[test] +fn phantom_in_a_section_heading_reserves_width_and_paints_nothing() { + // Review finding 4, verbatim shape: the flattened heading parser must + // build the same `Inline::Phantom` as the main loop, not visibly typeset + // the group contents with no width reserved. + let ghost = "\\begin{document}\\section{A\\phantom{X}B}\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + let texts = page_texts(ghost); + assert!( + !texts.iter().any(|t| t.contains('X')), + "X must stay invisible: {texts:?}" + ); + // Same width reservation as really typesetting the heading text: with + // spaces around it the word items line up exactly with the visible oracle. + let spaced_ghost = "\\begin{document}\\section{A \\phantom{X} B}\\end{document}"; + let spaced_real = "\\begin{document}\\section{A X B}\\end{document}"; + assert!(messages(spaced_ghost).is_empty(), "{:?}", messages(spaced_ghost)); + assert_eq!(position_of(spaced_ghost, "A"), position_of(spaced_real, "A")); + assert_eq!(position_of(spaced_ghost, "B"), position_of(spaced_real, "B")); + let spaced_texts = page_texts(spaced_ghost); + assert!( + !spaced_texts.iter().any(|t| t.contains('X')), + "X must stay invisible: {spaced_texts:?}" + ); +} + +#[test] +fn phantom_in_a_figure_caption_reserves_width_and_paints_nothing() { + // Review finding 4, second shape: captions share the flattened + // `inlines_from_tokens` path, so `\caption{A\phantom{X}B}` must behave + // the same way (captions are centred, so this pins the relative A/B + // geometry and invisibility rather than absolute positions). + let ghost = "\\begin{document}\\begin{figure}\\caption{A\\phantom{X}B}\\end{figure}\\end{document}"; + let spaced = "\\begin{document}\\begin{figure}\\caption{A \\phantom{X} B}\\end{figure}\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + assert!(messages(spaced).is_empty(), "{:?}", messages(spaced)); + for (label, source) in [("ghost", ghost), ("spaced", spaced)] { + let texts = page_texts(source); + assert!( + !texts.iter().any(|t| t.contains('X')), + "{label}: X must stay invisible: {texts:?}" + ); + } + // Unspaced B sits exactly where the spaced B sits minus the two + // inter-word gaps: the phantom reserved precisely X's width. + let (ax, _) = position_of(ghost, "A"); + let (bx, _) = position_of(ghost, "B"); + let (sax, _) = position_of(spaced, "A"); + let (sbx, _) = position_of(spaced, "B"); + let expected = (sbx - sax) - 2.0 * word_space(BODY_SIZE_PT, Font::TimesRoman); + assert!( + ((bx - ax) - expected).abs() < 0.02, + "caption phantom must reserve X's width: gap={} expected={}", + bx - ax, + expected + ); +} + +#[test] +fn phantom_around_underline_reserves_the_rule_depth() { + // Review finding 5: the underline rule hangs below the content box, so + // the following baseline must land where the real render puts it, not + // where size-based text extents alone would put it. + let real = "\\begin{document}\\underline{g}ab\\\\cd\\end{document}"; + let ghost = "\\begin{document}\\phantom{\\underline{g}}ab\\\\cd\\end{document}"; + let bare = "\\begin{document}ab\\\\cd\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + assert_eq!(page_texts(ghost), ["ab", "cd"]); + assert_eq!(position_of(ghost, "cd"), position_of(real, "cd")); + assert_eq!(position_of(ghost, "ab"), position_of(real, "ab")); + // The rule genuinely deepens the line: past where no phantom sits. + assert!(position_of(ghost, "cd").1 > position_of(bare, "cd").1); +} + #[test] fn math_mode_phantoms_are_unaffected() { let source = From ae26b39c8fc73c6d63e0b753a119e033a6a4763a Mon Sep 17 00:00:00 2001 From: "muse-spark-1.3-contributor" Date: Thu, 17 Sep 2026 12:37:14 -0700 Subject: [PATCH 3/4] compiler: nested commands, caption centering, trailing glue and line-break fixes for text-mode \phantom (#511 review round 2) A third independent review confirmed the underline-depth fix and its regression tests hold, then found 5 more issues in the flattened-context and layout paths. Findings 1 (vendor re-pin) and 5 (list-label layout) remain explicitly out of scope, per the ruling on this PR's body. 2 (nested commands dropped in a heading/caption phantom): the flattened inlines_from_tokens phantom arm only honored its own limited arm set, so \section{A\phantom{\rule{40pt}{1pt}}B} dropped \rule and leaked its literal tokens as text instead of reserving a 40pt box. Now routes the argument through the ordinary box dispatch (box_inlines), the same call the main loop's phantom handling already makes. 3 (caption centering ignores phantom width): figure/table caption centering summed only Inline::Text widths, so a phantom's reserved width shifted the visible caption off-center. New caption_box_width/ caption_advance mirror emit's own (x, content_end) cursor transitions arm-by-arm for every inline caption content can hold, so the phantom's width is counted like any other box. 4 (trailing interword glue dropped): a trailing space inside a phantom argument is now preserved as the one interword gap TeX's \hbox{X } reserves. An initial attempt appended a TextGlue node instead, which a probe showed double-reserved the space; replaced with a zero-width Inline::Text carrying space_before before checking in. 6 (unbreakable phantom always wraps on overflow): an overwide, unbreakable phantom box now only wraps at an available break point (a pending interword gap or a recorded line space on the current line); otherwise it and its glued tail stay overfull, matching TeX. A new unbreakable_tail cursor flag, set only by this specific refusal and cleared on every newline, keeps ordinary glued runs (e.g. a long \url's pieces) wrapping normally -- confirmed by rerunning the URL-wrap regression test, which an earlier, broader gate attempt had broken. 7 (phantom swallows reference warnings): visit_inline_references now recurses into Inline::Phantom, so \phantom{\ref{missing}} warns exactly like a bare \ref{missing}. Tests (crates/compiler/tests/text_phantom.rs, +5): each reproduces the review's exact example; 12 passed/5 failed before the fix, 17 passed/0 failed after. Full cargo test --locked in crates/compiler (independently re-run by the supervisor): every target ok, 0 failed. Implementation-Agent: muse-spark-1.3-contributor (Muse Code, lane phantom-review-fix-2, slice 1) Commit-Executor: daniel-muse-lead (Claude Sonnet) Reviewed-by: daniel-muse-lead (Claude Sonnet) Co-authored-by: d-q222 <279808976+d-q222@users.noreply.github.com> --- crates/compiler/src/layout.rs | 337 ++++++++++++++++++++++++-- crates/compiler/src/parser.rs | 14 +- crates/compiler/src/parser/colors.rs | 33 ++- crates/compiler/tests/text_phantom.rs | 119 +++++++++ 4 files changed, 481 insertions(+), 22 deletions(-) diff --git a/crates/compiler/src/layout.rs b/crates/compiler/src/layout.rs index d5ab06585..313f0f287 100644 --- a/crates/compiler/src/layout.rs +++ b/crates/compiler/src/layout.rs @@ -591,6 +591,7 @@ struct TabbingUndo { content_end: f64, line_fills_len: usize, line_spaces_len: usize, + unbreakable_tail: bool, closed_line_skip: Option, collected_labels: BTreeMap, footnotes: (usize, usize), @@ -609,6 +610,7 @@ impl TabbingUndo { content_end: c.content_end, line_fills_len: c.line_fills.len(), line_spaces_len: c.line_spaces.len(), + unbreakable_tail: c.unbreakable_tail, closed_line_skip: c.closed_line_skip, collected_labels: c.collected_labels.clone(), footnotes: c.footnotes.undo_point(), @@ -633,6 +635,7 @@ impl TabbingUndo { c.content_end = self.content_end; c.line_fills.truncate(self.line_fills_len); c.line_spaces.truncate(self.line_spaces_len); + c.unbreakable_tail = self.unbreakable_tail; c.closed_line_skip = self.closed_line_skip; c.collected_labels = self.collected_labels; c.footnotes.rollback(self.footnotes); @@ -660,6 +663,10 @@ pub struct LayoutCursor { /// after the space, natural width), consumed by `justify_line` when the /// line wraps and cleared whenever a line or block ends. line_spaces: Vec<(usize, f64)>, + /// A phantom box just refused to wrap for lack of a breakable space, so + /// the glued tail behind it must stay overfull on this line too (see + /// `place`). Set only by that refusal, cleared by `newline`. + unbreakable_tail: bool, /// Whether the block being rendered is set justified (body paragraphs, /// list items, `quote`); off for headings, captions, `center`/`flush*` /// and displays. @@ -749,6 +756,7 @@ impl LayoutCursor { content_end: MARGIN_PT, line_fills: Vec::new(), line_spaces: Vec::new(), + unbreakable_tail: false, justify: false, first_block: true, constraints, @@ -862,6 +870,7 @@ impl LayoutCursor { fn newline(&mut self, size: f64) { self.line_spaces.clear(); + self.unbreakable_tail = false; self.closed_line_skip = None; self.resolve_hfill(); self.align_current_line(); @@ -998,7 +1007,20 @@ impl LayoutCursor { self.x = self.content_end; } let (w, span) = shaped_width(&text, size, font, span, &mut self.diagnostics); - if self.x > self.left_edge() && self.x + w > self.right_edge() { + // A break needs a break point: with no pending interword gap before + // this run and no earlier recorded space on the line, a glued tail + // the phantom arm already refused to break stays overfull on the + // current line, as TeX does (so e.g. the `B` in + // `A\hphantom{\rule{500pt}{1pt}}B` follows its box instead of + // wrapping away from it). Ordinary glued runs (a long `\url`'s + // pieces) still wrap: the flag below is only ever set by that + // refusal. + if self.x > self.left_edge() + && self.x + w > self.right_edge() + && (self.x > self.content_end + || !self.line_spaces.is_empty() + || !self.unbreakable_tail) + { self.wrap_line(size); } self.note_space(); @@ -1940,16 +1962,12 @@ impl LayoutCursor { self.x = MARGIN_PT; } Block::FigureCaption { content } => { - let width: f64 = content - .iter() - .map(|inline| match inline { - Inline::Text { text, .. } => { - glyph_width(text, body_size, Font::TimesRoman) - + word_space(body_size, Font::TimesRoman) - } - _ => 0.0, - }) - .sum(); + // Centre the full invisible box: every inline width counts, + // including a phantom's reserved geometry (see + // `caption_content_width`), so e.g. + // `\caption{A\phantom{WWWW}B}` centres A, the blank and B + // together rather than shifting the visible text right. + let width: f64 = self.caption_box_width(content, body_size); self.x = MARGIN_PT + (self.constraints.measure_pt - width).max(0.0) / 2.0; emit(self, content, body_size, Font::TimesRoman); self.newline(body_size); @@ -2082,6 +2100,277 @@ impl LayoutCursor { self.constraints.font_size_pt } + /// The centred width of a figure caption's content: a side-effect-free + /// simulation of the `(x, content_end)` cursor state `emit` threads + /// through the same inlines, so the centring offset accounts for every + /// inline rather than just `Inline::Text`. Each arm mirrors its `emit` + /// counterpart exactly — including `space_before` rewinds of the eagerly + /// reserved interword gap, which is why e.g. `\\caption{A\\phantom{WWWW}B}` + /// centres the full invisible box while `\\caption{A X B}` and the plain + /// runs keep their long-standing positions. Math is measured through a + /// throwaway diagnostics sink (the real emission reports those once); + /// display math, tabulars, graphics and footnote marks are not counted + /// (as before). + fn caption_box_width(&self, inlines: &[Inline], size: f64) -> f64 { + let mut x = 0.0f64; + let mut content_end = 0.0f64; + for inline in inlines { + self.caption_advance(inline, size, &mut x, &mut content_end); + } + content_end + } + + /// One inline's step of the simulation above (`x`/`content_end` are the + /// cursor's, relative to the line start). + fn caption_advance(&self, inline: &Inline, size: f64, x: &mut f64, content_end: &mut f64) { + // A `place`-like run: rewind the eagerly reserved gap when glued, + // then reserve the next gap. The reserve uses the run's own size and + // face, exactly as `place` does. + let place = |text: &str, + style: &crate::parser::TextStyle, + space_before: bool, + x: &mut f64, + content_end: &mut f64| { + if !space_before { + *x = *content_end; + } + let text_size = style.size.map_or(size, |level| { + size_declaration_pt(level, self.constraints.font_size_pt) + }); + let font = style_font(*style); + *content_end = *x + glyph_width(text, text_size, font); + *x = *content_end + word_space(text_size, font); + }; + match inline { + Inline::Text { + text, + style, + space_before, + .. + } => place(text, style, *space_before, x, content_end), + Inline::Verbatim { + text, + space_before, + .. + } => { + if !space_before { + *x = *content_end; + } + *content_end = *x + glyph_width(text, size, Font::Courier); + *x = *content_end + word_space(size, Font::Courier); + } + Inline::Discretionary { + nobreak, style, .. + } => { + if !nobreak.is_empty() { + // The generic path always glues the surviving text, set + // in the ambient size with the run's face. + *x = *content_end; + let font = style_font(*style); + *content_end = *x + glyph_width(nobreak, size, font); + *x = *content_end + word_space(size, font); + } + } + Inline::Phantom { + content, + horizontal, + space_before, + .. + } => { + if !space_before { + *x = *content_end; + } + // The argument is its own unbroken box; a `vphantom` + // reserves no width, exactly like the emission arm. The + // trailing reserve uses the ambient size and face, as the + // emission arm does. + let width = if *horizontal { + self.caption_box_width(content, size) + } else { + 0.0 + }; + *content_end = *x + width; + *x = *content_end + word_space(size, Font::TimesRoman); + } + // Transparent wrappers set their content inline. `Underline` + // honours `space_before`; `ColorBox`/`Transform` ignore it, + // exactly like their emission arms. + Inline::Underline(u) => { + if !u.space_before { + *x = *content_end; + } + for inner in &u.content { + self.caption_advance(inner, size, x, content_end); + } + } + Inline::ColorBox(b) => { + for inner in &b.content { + self.caption_advance(inner, size, x, content_end); + } + } + Inline::Transform(b) => { + for inner in &b.content { + self.caption_advance(inner, size, x, content_end); + } + } + Inline::TextGlue { em, .. } => { + *x += em * size; + *content_end = *x; + } + Inline::HSpace { + pt, + space_before_pt, + space_after_pt, + .. + } => { + *x = *content_end + space_before_pt + pt + space_after_pt; + *content_end = *x; + } + Inline::Kern { amount, style, .. } => { + use crate::text_builtins::{self as tb}; + let text_size = style.size.map_or(size, |level| { + size_declaration_pt(level, self.constraints.font_size_pt) + }); + let cx = tb::DimenContext { + quad: tb::pt_to_sp(text_size), + ..Default::default() + }; + *x = *content_end + tb::sp_to_pt(amount.resolve(&cx)); + *content_end = *x; + } + Inline::Rule { + rule, + style, + space_before, + .. + } => { + use crate::text_builtins::{self as tb}; + if !space_before { + *x = *content_end; + } + let text_size = style.size.map_or(size, |level| { + size_declaration_pt(level, self.constraints.font_size_pt) + }); + let font = style_font(*style); + let cx = tb::DimenContext { + quad: tb::pt_to_sp(text_size), + x_height: tb::pt_to_sp(x_height_pt(font, text_size)), + text_width: tb::pt_to_sp(self.constraints.measure_pt), + line_width: tb::pt_to_sp(self.right_edge() - self.left_edge()), + column_width: tb::pt_to_sp(self.constraints.measure_pt), + }; + // `place_rule` glues the rule box like a word in the run's + // size and face. + let width = tb::sp_to_pt(rule.resolve(&cx).width); + *content_end = *x + width; + *x = *content_end + word_space(text_size, font); + } + Inline::Logo { + logo, + style, + space_before, + .. + } => { + use crate::text_builtins::{self as tb}; + if !space_before { + *x = *content_end; + } + let text_size = style.size.map_or(size, |level| { + size_declaration_pt(level, self.constraints.font_size_pt) + }); + let font = style_font(*style); + let metrics = Core14LogoMetrics { + font, + size: text_size, + }; + // `place_logo` glues the construction like a word in the + // run's size and face. + let width = tb::sp_to_pt(tb::layout_logo(*logo, &metrics).width); + *content_end = *x + width; + *x = *content_end + word_space(text_size, font); + } + Inline::Math { list, space_before, .. } => { + if !space_before { + *x = *content_end; + } + // Measured through a throwaway sink: the real emission + // reports any diagnostics exactly once. + let width = math::layout(list, size, &mut Vec::new()).width; + *content_end = *x + width; + *x = *content_end + word_space(size, Font::TimesRoman); + } + Inline::Reference { + key, + page, + equation, + space_before, + .. + } => { + if !space_before { + *x = *content_end; + } + // The same text the emission arm places, in the same face. + let (text, font) = match self.resolved_labels.get(key) { + Some(value) => { + let text = if *page { + value.page_text.clone() + } else { + value.number.clone() + }; + let text = if *equation { format!("({text})") } else { text }; + (text, Font::TimesRoman) + } + None if *equation => ("(??)".to_string(), Font::TimesRoman), + None => ("??".to_string(), Font::TimesBold), + }; + // `(??)` mixes faces; measure piece-wise like the emission. + let width = if text == "(??)" { + glyph_width("(", size, Font::TimesRoman) + + glyph_width("??", size, Font::TimesBold) + + glyph_width(")", size, Font::TimesRoman) + } else { + glyph_width(&text, size, font) + }; + *content_end = *x + width; + *x = *content_end + word_space(size, Font::TimesRoman); + } + Inline::CleverReference { + keys, + page, + range, + label_only, + capitalise, + space_before, + .. + } => { + if !space_before { + *x = *content_end; + } + let (text, unresolved) = clever_reference_text( + keys, + &self.resolved_labels, + &self.cleveref, + *page, + *range, + *label_only, + *capitalise, + ); + let font = if unresolved { Font::TimesBold } else { Font::TimesRoman }; + *content_end = *x + glyph_width(&text, size, font); + *x = *content_end + word_space(size, Font::TimesRoman); + } + Inline::ThePage { space_before, .. } => { + if !space_before { + *x = *content_end; + } + let text = self.page_style.format(self.page_value); + *content_end = *x + glyph_width(&text, size, Font::TimesRoman); + *x = *content_end + word_space(size, Font::TimesRoman); + } + _ => {} + } + } + /// Lays `inlines` out in a detached cursor as one box whose origin is its /// first baseline (a `tabular` entry or `@{}` text): a single unbroken /// line when `measure` is `None`, else a paragraph of that width. Returns @@ -2590,6 +2879,9 @@ fn visit_inline_references(inlines: &[Inline], visitor: &mut impl FnMut(&str, Sp } Inline::Transform(b) => visit_inline_references(&b.content, visitor), Inline::Underline(u) => visit_inline_references(&u.content, visitor), + // A phantom reserves geometry but still mentions its content: + // `\phantom{\ref{missing}}` warns exactly like the bare `\ref`. + Inline::Phantom { content, .. } => visit_inline_references(content, visitor), _ => {} } } @@ -3315,12 +3607,25 @@ fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { } let measured = c.inline_box(content, size, None); let width = if *horizontal { measured.0.width } else { 0.0 }; - // An unbreakable box never starts past the right edge: the - // same overflow check `place`/`place_rule`/`place_math` - // apply before reserving anything, so the break happens at - // the preceding space exactly as it would for real content. - if c.x > c.left_edge() && c.x + width > c.right_edge() { + // An unbreakable box never starts past the right edge while a + // break point is available: the same overflow check + // `place`/`place_rule`/`place_math` apply before reserving + // anything, so the break happens at the preceding space + // exactly as it would for real content. With no breakable + // space (no pending interword gap before the box and no + // earlier recorded space on the line), the glued sequence + // stays overfull on the current line, as TeX does, so e.g. + // `A\hphantom{\rule{500pt}{1pt}}B` does not move. + if c.x > c.left_edge() + && c.x + width > c.right_edge() + && (c.x > c.content_end || !c.line_spaces.is_empty()) + { c.wrap_line(size); + } else if c.x > c.left_edge() && c.x + width > c.right_edge() { + // No breakable space precedes this unbreakable box, so it + // stays overfull on the current line (see `place`): the + // glued tail behind it must stay too. + c.unbreakable_tail = true; } if *vertical { // Text runs are laid out with size-based extents (see diff --git a/crates/compiler/src/parser.rs b/crates/compiler/src/parser.rs index ce094fcb2..d09336b9f 100644 --- a/crates/compiler/src/parser.rs +++ b/crates/compiler/src/parser.rs @@ -8460,9 +8460,12 @@ impl P<'_> { // `\section{A\phantom{X}B}` visibly typeset `X` with no width // reserved. Consume the group and emit the same // `Inline::Phantom` the main token loop builds - // (`P::text_phantom`), with the argument parsed recursively so - // nested commands resolve exactly as they do elsewhere in - // flattened content. + // (`P::text_phantom`), with the argument parsed through the + // ordinary box dispatch (`P::box_inlines`) in the flattened + // style in force here, so nested commands such as + // `\rule` resolve exactly as they do in running text (a + // recursive `inlines_from_tokens` would only honour this + // flattened path's own limited set of arms). TokenKind::Command(name) if matches!(name.as_str(), "phantom" | "hphantom" | "vphantom") => { @@ -8474,7 +8477,10 @@ impl P<'_> { } else { input.token.span }; - let inner_content = self.inlines_from_tokens(inner, style); + let outer_style = self.style; + self.style = style; + let inner_content = self.box_inlines(inner); + self.style = outer_style; content.push(Inline::Phantom { content: inner_content, horizontal: name.as_str() != "vphantom", diff --git a/crates/compiler/src/parser/colors.rs b/crates/compiler/src/parser/colors.rs index 1bfb9e533..6a3957145 100644 --- a/crates/compiler/src/parser/colors.rs +++ b/crates/compiler/src/parser/colors.rs @@ -227,6 +227,25 @@ impl P<'_> { /// A box argument parsed with the ordinary dispatch as one group in the /// current style (`\hbox`: restricted horizontal mode ignores `\par`). pub(super) fn box_inlines(&mut self, tokens: Vec) -> Vec { + // TeX's `\hbox{X }` reserves the trailing interword glue as part of + // the box. A `Space` at the end of the argument has no following run + // to carry `space_before` (spaces only ride along on the next run), + // so without this it would vanish and e.g. `A\phantom{X }B` would + // leave `B` one word space too far left. Comments are invisible and + // skipped when looking for it. The empty run below materialises + // exactly the interword gap the layout already holds pending after + // the last run (`place` eagerly reserves it in `x` but only a + // following run folds it into `content_end`, which is what the box + // is measured by): zero-width text with `space_before` keeps `x` + // and sets `content_end` to it, adding precisely one word space. + // Appending real glue (`Inline::TextGlue`) instead would stack a + // second space on top of that pending one. + let trailing_space = tokens + .iter() + .rev() + .find(|input| !matches!(input.token.kind, TokenKind::Comment)) + .filter(|input| matches!(input.token.kind, TokenKind::Space)) + .map(|input| input.token.span); let outer_tokens = std::mem::replace(&mut self.t, std::rc::Rc::new(tokens)); let outer_index = std::mem::replace(&mut self.i, 0); let outer_style = self.style; @@ -237,6 +256,7 @@ impl P<'_> { let mut para = Vec::new(); self.parse_stream(&mut blocks, &mut para); self.flush_paragraph(&mut blocks, &mut para); + let end_style = self.style; self.block_dependencies.truncate(outer_dependency_blocks); // The box's paragraphs never reach `blocks`: their leadings must not // reach `block_par_leading` either, which carries exactly one entry @@ -246,7 +266,7 @@ impl P<'_> { self.i = outer_index; self.style = outer_style; self.pending_item_label = outer_label; - blocks + let mut inlines: Vec = blocks .into_iter() .flat_map(|block| match block { Block::Paragraph(inlines) @@ -254,7 +274,16 @@ impl P<'_> { | Block::ListItem { content: inlines, .. } => inlines, _ => Vec::new(), }) - .collect() + .collect(); + if let Some(span) = trailing_space { + inlines.push(Inline::Text { + text: String::new(), + span, + style: end_style, + space_before: true, + }); + } + inlines } /// `\color`/`\textcolor` at `index` of a flat token run: the index after diff --git a/crates/compiler/tests/text_phantom.rs b/crates/compiler/tests/text_phantom.rs index 7af3706ba..61e2c8099 100644 --- a/crates/compiler/tests/text_phantom.rs +++ b/crates/compiler/tests/text_phantom.rs @@ -257,3 +257,122 @@ fn math_mode_phantoms_are_unaffected() { let blocks = blocks_debug(source); assert!(blocks.contains("Phantom"), "{blocks}"); } + +#[test] +fn phantom_with_nested_rule_in_section_reserves_rule_width() { + // Finding 2: `\section{A\phantom{\rule{40pt}{1pt}}B}` must route the + // phantom argument through ordinary box dispatch, so the nested `\rule` + // reserves a 40pt box instead of leaking its literal tokens as text. + let ghost = + "\\begin{document}\\section{A\\phantom{\\rule{40pt}{1pt}}B}\\end{document}"; + let empty = "\\begin{document}\\section{A\\phantom{}B}\\end{document}"; + assert!(messages(ghost).is_empty(), "{ghost:?}: {:?}", messages(ghost)); + // The rule paints nothing and its dimensions never surface as text. + let texts = page_texts(ghost); + assert!( + !texts.iter().any(|t| t.contains("40pt") || t.contains("1pt")), + "rule dimensions must not leak as text: {texts:?}" + ); + // ... but B sits exactly 40pt past where an empty phantom leaves it. + let (b_rule, _) = position_of(ghost, "B"); + let (b_empty, _) = position_of(empty, "B"); + assert!( + (b_rule - b_empty - 40.0).abs() < 0.02, + "B must sit one 40pt rule past the empty-phantom spot: {b_empty} -> {b_rule}" + ); + // The parsed phantom really holds a rule box, not literal words. + let blocks = blocks_debug(ghost); + assert!(blocks.contains("Phantom"), "{blocks}"); + assert!(blocks.contains("Rule"), "{blocks}"); +} + +#[test] +fn figure_caption_centering_counts_phantom_width() { + // Finding 3: `\caption{A\phantom{WWWW}B}` must centre the full invisible + // box, so the laid-out line is symmetric within the measure. + use flashtex_compiler::layout::{LayoutConstraints, MARGIN_PT}; + let source = "\\begin{document}\\begin{figure}\\caption{A\\phantom{WWWW}B}\\end{figure}\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + let output = compile(source); + let items = &output.pages[0].items; + let first = items.first().expect("caption must lay out items"); + let last = items.last().expect("caption must lay out items"); + assert_eq!(first.text, "Figure 1:"); + assert_eq!(last.text, "B"); + let measure = LayoutConstraints::default().measure_pt; + let left_gap = first.x_pt - MARGIN_PT; + let right_gap = + (MARGIN_PT + measure) - (last.x_pt + text_width("B", BODY_SIZE_PT, Font::TimesRoman)); + assert!( + (left_gap - right_gap).abs() < 0.03, + "caption line must be centred: left={left_gap} right={right_gap}" + ); + // The phantom still paints nothing. + assert!(!page_texts(source).iter().any(|t| t.contains('W') && t.len() > 1)); +} + +#[test] +fn phantom_preserves_trailing_interword_glue() { + // Finding 4: `A\phantom{X }B` must reserve `X` plus exactly one trailing + // interword glue (TeX's `\hbox{X }`), so B sits one `X`-width plus one + // word space past A. (A spaced oracle such as `A X B` would additionally + // carry the source gap between `A` and the box, which the glued source + // here does not have.) + let ghost = "\\begin{document}A\\phantom{X }B\\end{document}"; + assert!(messages(ghost).is_empty(), "{:?}", messages(ghost)); + let (ax, _) = position_of(ghost, "A"); + let (bx, _) = position_of(ghost, "B"); + let expected = text_width("A", BODY_SIZE_PT, Font::TimesRoman) + + text_width("X", BODY_SIZE_PT, Font::TimesRoman) + + word_space(BODY_SIZE_PT, Font::TimesRoman); + assert!( + ((bx - ax) - expected).abs() < 0.02, + "B must start past X plus one word space: A x={ax}, B x={bx}, gap={}, expected={expected}", + bx - ax + ); + assert_eq!(page_texts(ghost), ["A", "B"]); +} + +#[test] +fn glued_overwide_phantom_stays_on_the_line() { + // Finding 6: `A\hphantom{\rule{500pt}{1pt}}B` has no breakable space, so + // TeX keeps the unbreakable sequence on the current overfull line. + let source = "\\begin{document}A\\hphantom{\\rule{500pt}{1pt}}B\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + let (ax, ay) = position_of(source, "A"); + let (bx, by) = position_of(source, "B"); + assert_eq!(ay, by, "glued overfull box must not wrap: A y={ay}, B y={by}"); + let gap = bx - ax - text_width("A", BODY_SIZE_PT, Font::TimesRoman); + assert!( + (gap - 500.0).abs() < 0.02, + "B must start one 500pt rule past A's end: A x={ax}, B x={bx}, gap={gap}" + ); + assert_eq!(page_texts(source), ["A", "B"]); +} + +#[test] +fn phantom_reference_still_warns_when_undefined() { + // Finding 7: `\phantom{\ref{missing}}` must emit the same + // undefined-reference warning as the bare `\ref{missing}`. + // The undefined-reference warning is emitted at layout time, so read + // `compile_full` diagnostics rather than parser diagnostics. + let layout_notes = |source: &str| { + compile(source) + .diagnostics + .into_iter() + .map(|d| d.message) + .collect::>() + }; + let ghost = "\\begin{document}\\phantom{\\ref{missing}}x\\end{document}"; + let bare = "\\begin{document}\\ref{missing}x\\end{document}"; + let ghost_notes = layout_notes(ghost); + let bare_notes = layout_notes(bare); + assert!( + bare_notes.iter().any(|m| m.contains("undefined")), + "oracle must warn: {bare_notes:?}" + ); + assert!( + ghost_notes.iter().any(|m| m.contains("undefined")), + "phantom must not swallow the warning: {ghost_notes:?}" + ); +} From 70b1656aced9e7c816f3921903bc7ac85c58fcc1 Mon Sep 17 00:00:00 2001 From: "muse-spark-1.3-contributor" Date: Thu, 17 Sep 2026 15:04:38 -0700 Subject: [PATCH 4/4] compiler: leading interword glue, no empty page items, real-emitter caption width for text-mode \phantom (#511 review round 3) A fourth independent review confirmed the previous round's fixes and found 4 more issues. 1 (leading space, mirror of the already-fixed trailing case): box_inlines only detected a trailing space; a leading one (A\phantom{ X}B) or a whitespace-only argument (A\phantom{ }B) reserved no glue at all. Detects the leading space the same way (first non-comment token), prepending a zero-width marker before the content with the box's entry style; a whitespace-only box now gets both markers and folds to exactly one reserved space, never two or zero. 2 (the trailing-glue fix from round 2 leaked an empty page item): box_inlines backs \colorbox, \underline, \uline and other box commands too, not just \phantom -- the zero-width marker it appends is a real Inline::Text that emits into the page item stream for all of them, so e.g. A\colorbox{yellow}{X }B produced a genuine empty-string TextItem reaching PDF text emission, selection and snapshots. `place` now skips pushing a TextItem for empty text, keeping every other cursor step (rewind, wrap check, extents, content_end/x advance) identical. 3 (caption centering was a 270-line hand-mirror of the real emitter, already diverging): deleted caption_advance entirely and rewrote caption_box_width as a 6-line measurement through the existing inline_box helper -- the same detached-cursor emitter this PR's own phantom arm already uses -- with diagnostics from the measurement pass truncated so the real emission still reports them exactly once. Centering is now correct by construction for every inline kind, including the ones the old hand-mirror's catch-all silently excluded (Graphic, Tabular, Footnote, display math, Label). 4 (the two agreed out-of-scope limitations were stated nowhere): the three text-mode phantom inventory descriptions now qualify the explicit-list-label gap, and the phantom emission arm has a comment recording the vendor/compiler pin status. Checked the vendor pin directly rather than assuming: it still carries only math-mode Nucleus::Phantom, so the "needs a re-pin" wording is accurate as of this push. Tests: 4 new in text_phantom.rs (leading glue, leading+trailing together, whitespace-only, no-empty-item), 2 new in references_and_figures.rs (multi-line caption centering on its own width -- the old hand-mirror summed both lines, ~8pt of asymmetry; diagnostic-reported-once guard for the new measurement path). Each fails on the pre-fix code and passes after. Full cargo test --locked in crates/compiler (independently re-run by the supervisor): 78 targets, 933 passed, 0 failed, 10 pre-existing ignores. Implementation-Agent: muse-spark-1.3-contributor (Muse Code, lane phantom-review-fix-3, slice 1) Commit-Executor: daniel-muse-lead (Claude Sonnet) Reviewed-by: daniel-muse-lead (Claude Sonnet) Co-authored-by: d-q222 <279808976+d-q222@users.noreply.github.com> --- .../Resources/supported-latex.json | 6 +- crates/compiler/src/layout.rs | 325 +++--------------- crates/compiler/src/parser/colors.rs | 35 +- crates/compiler/src/supported.rs | 6 +- .../compiler/supported/supported-latex.json | 6 +- .../compiler/tests/references_and_figures.rs | 47 +++ crates/compiler/tests/text_phantom.rs | 85 +++++ docs/user/compiler.md | 6 +- 8 files changed, 219 insertions(+), 297 deletions(-) diff --git a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json index a7e8f101c..c867e1904 100644 --- a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json +++ b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json @@ -245,9 +245,9 @@ {"name": "LaTeX", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX", "renders": true}, {"name": "LaTeXe", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon", "renders": true}, {"name": "rule", "mode": "text", "origin": "text_dispatch", "arguments": "[raise]{dimension}{dimension}", "description": "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth", "renders": true}, - {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted", "renders": true}, - {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth)", "renders": true}, - {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width)", "renders": true}, + {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted (an explicit \\item[...] label keeps plain text, so the reserved width is lost there)", "renders": true}, + {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth; an explicit \\item[...] label keeps plain text, so the reserved width is lost there)", "renders": true}, + {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width; an explicit \\item[...] label keeps plain text)", "renders": true}, {"name": "thinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .16667em (math: thin muskip)", "renders": true}, {"name": "negthinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern -.16667em", "renders": true}, {"name": "medspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .2222em", "renders": true}, diff --git a/crates/compiler/src/layout.rs b/crates/compiler/src/layout.rs index 313f0f287..5e8728276 100644 --- a/crates/compiler/src/layout.rs +++ b/crates/compiler/src/layout.rs @@ -1025,20 +1025,27 @@ impl LayoutCursor { } self.note_space(); self.ensure_extents(size, size * (LINE_SPACING - 1.0)); - let item = TextItem { - text, - x_pt: round2(self.x), - baseline_y_pt: round2(self.y), - font_size_pt: size, - span, - font, - rule: None, - }; - self.pages - .last_mut() - .expect("at least one page") - .items - .push(item); + // Box-edge gap markers (`box_inlines`) are zero-width runs whose + // only job is the cursor advance above: reserving interword glue + // inside the box. They must never reach the page as a `""` item + // (PDF text emission, selection/search, snapshots) — the same + // empty guard `push_item` already applies below. + if !text.is_empty() { + let item = TextItem { + text, + x_pt: round2(self.x), + baseline_y_pt: round2(self.y), + font_size_pt: size, + span, + font, + rule: None, + }; + self.pages + .last_mut() + .expect("at least one page") + .items + .push(item); + } self.content_end = self.x + w; self.x += w + word_space(size, font); } @@ -1964,7 +1971,7 @@ impl LayoutCursor { Block::FigureCaption { content } => { // Centre the full invisible box: every inline width counts, // including a phantom's reserved geometry (see - // `caption_content_width`), so e.g. + // `caption_box_width`), so e.g. // `\caption{A\phantom{WWWW}B}` centres A, the blank and B // together rather than shifting the visible text right. let width: f64 = self.caption_box_width(content, body_size); @@ -2100,275 +2107,20 @@ impl LayoutCursor { self.constraints.font_size_pt } - /// The centred width of a figure caption's content: a side-effect-free - /// simulation of the `(x, content_end)` cursor state `emit` threads - /// through the same inlines, so the centring offset accounts for every - /// inline rather than just `Inline::Text`. Each arm mirrors its `emit` - /// counterpart exactly — including `space_before` rewinds of the eagerly - /// reserved interword gap, which is why e.g. `\\caption{A\\phantom{WWWW}B}` - /// centres the full invisible box while `\\caption{A X B}` and the plain - /// runs keep their long-standing positions. Math is measured through a - /// throwaway diagnostics sink (the real emission reports those once); - /// display math, tabulars, graphics and footnote marks are not counted - /// (as before). - fn caption_box_width(&self, inlines: &[Inline], size: f64) -> f64 { - let mut x = 0.0f64; - let mut content_end = 0.0f64; - for inline in inlines { - self.caption_advance(inline, size, &mut x, &mut content_end); - } - content_end - } - - /// One inline's step of the simulation above (`x`/`content_end` are the - /// cursor's, relative to the line start). - fn caption_advance(&self, inline: &Inline, size: f64, x: &mut f64, content_end: &mut f64) { - // A `place`-like run: rewind the eagerly reserved gap when glued, - // then reserve the next gap. The reserve uses the run's own size and - // face, exactly as `place` does. - let place = |text: &str, - style: &crate::parser::TextStyle, - space_before: bool, - x: &mut f64, - content_end: &mut f64| { - if !space_before { - *x = *content_end; - } - let text_size = style.size.map_or(size, |level| { - size_declaration_pt(level, self.constraints.font_size_pt) - }); - let font = style_font(*style); - *content_end = *x + glyph_width(text, text_size, font); - *x = *content_end + word_space(text_size, font); - }; - match inline { - Inline::Text { - text, - style, - space_before, - .. - } => place(text, style, *space_before, x, content_end), - Inline::Verbatim { - text, - space_before, - .. - } => { - if !space_before { - *x = *content_end; - } - *content_end = *x + glyph_width(text, size, Font::Courier); - *x = *content_end + word_space(size, Font::Courier); - } - Inline::Discretionary { - nobreak, style, .. - } => { - if !nobreak.is_empty() { - // The generic path always glues the surviving text, set - // in the ambient size with the run's face. - *x = *content_end; - let font = style_font(*style); - *content_end = *x + glyph_width(nobreak, size, font); - *x = *content_end + word_space(size, font); - } - } - Inline::Phantom { - content, - horizontal, - space_before, - .. - } => { - if !space_before { - *x = *content_end; - } - // The argument is its own unbroken box; a `vphantom` - // reserves no width, exactly like the emission arm. The - // trailing reserve uses the ambient size and face, as the - // emission arm does. - let width = if *horizontal { - self.caption_box_width(content, size) - } else { - 0.0 - }; - *content_end = *x + width; - *x = *content_end + word_space(size, Font::TimesRoman); - } - // Transparent wrappers set their content inline. `Underline` - // honours `space_before`; `ColorBox`/`Transform` ignore it, - // exactly like their emission arms. - Inline::Underline(u) => { - if !u.space_before { - *x = *content_end; - } - for inner in &u.content { - self.caption_advance(inner, size, x, content_end); - } - } - Inline::ColorBox(b) => { - for inner in &b.content { - self.caption_advance(inner, size, x, content_end); - } - } - Inline::Transform(b) => { - for inner in &b.content { - self.caption_advance(inner, size, x, content_end); - } - } - Inline::TextGlue { em, .. } => { - *x += em * size; - *content_end = *x; - } - Inline::HSpace { - pt, - space_before_pt, - space_after_pt, - .. - } => { - *x = *content_end + space_before_pt + pt + space_after_pt; - *content_end = *x; - } - Inline::Kern { amount, style, .. } => { - use crate::text_builtins::{self as tb}; - let text_size = style.size.map_or(size, |level| { - size_declaration_pt(level, self.constraints.font_size_pt) - }); - let cx = tb::DimenContext { - quad: tb::pt_to_sp(text_size), - ..Default::default() - }; - *x = *content_end + tb::sp_to_pt(amount.resolve(&cx)); - *content_end = *x; - } - Inline::Rule { - rule, - style, - space_before, - .. - } => { - use crate::text_builtins::{self as tb}; - if !space_before { - *x = *content_end; - } - let text_size = style.size.map_or(size, |level| { - size_declaration_pt(level, self.constraints.font_size_pt) - }); - let font = style_font(*style); - let cx = tb::DimenContext { - quad: tb::pt_to_sp(text_size), - x_height: tb::pt_to_sp(x_height_pt(font, text_size)), - text_width: tb::pt_to_sp(self.constraints.measure_pt), - line_width: tb::pt_to_sp(self.right_edge() - self.left_edge()), - column_width: tb::pt_to_sp(self.constraints.measure_pt), - }; - // `place_rule` glues the rule box like a word in the run's - // size and face. - let width = tb::sp_to_pt(rule.resolve(&cx).width); - *content_end = *x + width; - *x = *content_end + word_space(text_size, font); - } - Inline::Logo { - logo, - style, - space_before, - .. - } => { - use crate::text_builtins::{self as tb}; - if !space_before { - *x = *content_end; - } - let text_size = style.size.map_or(size, |level| { - size_declaration_pt(level, self.constraints.font_size_pt) - }); - let font = style_font(*style); - let metrics = Core14LogoMetrics { - font, - size: text_size, - }; - // `place_logo` glues the construction like a word in the - // run's size and face. - let width = tb::sp_to_pt(tb::layout_logo(*logo, &metrics).width); - *content_end = *x + width; - *x = *content_end + word_space(text_size, font); - } - Inline::Math { list, space_before, .. } => { - if !space_before { - *x = *content_end; - } - // Measured through a throwaway sink: the real emission - // reports any diagnostics exactly once. - let width = math::layout(list, size, &mut Vec::new()).width; - *content_end = *x + width; - *x = *content_end + word_space(size, Font::TimesRoman); - } - Inline::Reference { - key, - page, - equation, - space_before, - .. - } => { - if !space_before { - *x = *content_end; - } - // The same text the emission arm places, in the same face. - let (text, font) = match self.resolved_labels.get(key) { - Some(value) => { - let text = if *page { - value.page_text.clone() - } else { - value.number.clone() - }; - let text = if *equation { format!("({text})") } else { text }; - (text, Font::TimesRoman) - } - None if *equation => ("(??)".to_string(), Font::TimesRoman), - None => ("??".to_string(), Font::TimesBold), - }; - // `(??)` mixes faces; measure piece-wise like the emission. - let width = if text == "(??)" { - glyph_width("(", size, Font::TimesRoman) - + glyph_width("??", size, Font::TimesBold) - + glyph_width(")", size, Font::TimesRoman) - } else { - glyph_width(&text, size, font) - }; - *content_end = *x + width; - *x = *content_end + word_space(size, Font::TimesRoman); - } - Inline::CleverReference { - keys, - page, - range, - label_only, - capitalise, - space_before, - .. - } => { - if !space_before { - *x = *content_end; - } - let (text, unresolved) = clever_reference_text( - keys, - &self.resolved_labels, - &self.cleveref, - *page, - *range, - *label_only, - *capitalise, - ); - let font = if unresolved { Font::TimesBold } else { Font::TimesRoman }; - *content_end = *x + glyph_width(&text, size, font); - *x = *content_end + word_space(size, Font::TimesRoman); - } - Inline::ThePage { space_before, .. } => { - if !space_before { - *x = *content_end; - } - let text = self.page_style.format(self.page_value); - *content_end = *x + glyph_width(&text, size, Font::TimesRoman); - *x = *content_end + word_space(size, Font::TimesRoman); - } - _ => {} - } + /// The centred width of a figure caption's content: the caption run + /// through the real `emit` in a detached `inline_box` cursor, so the + /// centring offset accounts for every inline kind by construction — + /// including a phantom's reserved geometry (which is why e.g. + /// `\\caption{A\\phantom{WWWW}B}` centres A, the blank and B together + /// rather than shifting the visible text right), and any image, table + /// or footnote mark a hand-mirrored walk would have to re-implement + /// arm by arm. Diagnostics the measurement reports are dropped: the + /// real emission in the caption arm reports them exactly once below. + fn caption_box_width(&mut self, inlines: &[Inline], size: f64) -> f64 { + let notes = self.diagnostics.len(); + let width = self.inline_box(inlines, size, None).0.width; + self.diagnostics.truncate(notes); + width } /// Lays `inlines` out in a detached cursor as one box whose origin is its @@ -3602,6 +3354,11 @@ fn emit(c: &mut LayoutCursor, inlines: &[Inline], size: f64, font: Font) { // box, not glue: unlike `hspace`, the eagerly reserved // inter-word space around it is kept (see `place`), so // `a \phantom{X} b` keeps both gaps while `X` paints nothing. + // Render-pipeline limitation (stated, not fixed): the + // pipeline builds against the pinned `vendor/compiler` + // mirror (PIN `c95977d6`, which carries only math-mode + // `Nucleus::Phantom`), so this text-mode node reaches the + // renderer only once vendor/ is re-pinned past it. if !space_before { c.x = c.content_end; } diff --git a/crates/compiler/src/parser/colors.rs b/crates/compiler/src/parser/colors.rs index 6a3957145..a03526d61 100644 --- a/crates/compiler/src/parser/colors.rs +++ b/crates/compiler/src/parser/colors.rs @@ -240,6 +240,11 @@ impl P<'_> { // and sets `content_end` to it, adding precisely one word space. // Appending real glue (`Inline::TextGlue`) instead would stack a // second space on top of that pending one. + let leading_space = tokens + .iter() + .find(|input| !matches!(input.token.kind, TokenKind::Comment)) + .filter(|input| matches!(input.token.kind, TokenKind::Space)) + .map(|input| input.token.span); let trailing_space = tokens .iter() .rev() @@ -275,7 +280,35 @@ impl P<'_> { _ => Vec::new(), }) .collect(); - if let Some(span) = trailing_space { + // TeX's `\hbox{ X}` reserves the leading interword glue just as + // `\hbox{X }` reserves the trailing one. The leading marker is the + // mirror of the trailing one below: a zero-width run with + // `space_before: false` *before* the content, so `place` rewinds to + // `content_end` (dropping outer glue the box is glued against) and + // eagerly reserves one word space the first content run then folds + // into the box. It carries the style in force where the space sits + // (the box entry style), so the reserved gap has that run's face. + // A box with no content at all (whitespace-only) needs the leading + // path: at x = 0 the trailing marker below has no pending gap to + // fold and would measure zero, while the leading one sets the gap + // itself. It still gets the trailing marker too, which folds that + // freshly set gap into the box — exactly one space either way, + // never two (the fold adds nothing of its own). + let has_content = !inlines.is_empty(); + let leading = leading_space.or(if !has_content { trailing_space } else { None }); + let trailing = trailing_space.or(if !has_content { leading_space } else { None }); + if let Some(span) = leading { + inlines.insert( + 0, + Inline::Text { + text: String::new(), + span, + style: outer_style, + space_before: false, + }, + ); + } + if let Some(span) = trailing { inlines.push(Inline::Text { text: String::new(), span, diff --git a/crates/compiler/src/supported.rs b/crates/compiler/src/supported.rs index 3ed39e51a..fe76a530e 100644 --- a/crates/compiler/src/supported.rs +++ b/crates/compiler/src/supported.rs @@ -323,9 +323,9 @@ const TEXT_COMMANDS: &[(&str, &str, &str)] = &[ ("LaTeX", "", "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX"), ("LaTeXe", "", "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon"), ("rule", "[raise]{dimension}{dimension}", "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth"), - ("phantom", "{...}", "empty box with the argument's width, height and depth; nothing is painted"), - ("hphantom", "{...}", "empty box with the argument's width only (zero height and depth)"), - ("vphantom", "{...}", "empty box with the argument's height and depth only (zero width)"), + ("phantom", "{...}", "empty box with the argument's width, height and depth; nothing is painted (an explicit \\item[...] label keeps plain text, so the reserved width is lost there)"), + ("hphantom", "{...}", "empty box with the argument's width only (zero height and depth; an explicit \\item[...] label keeps plain text, so the reserved width is lost there)"), + ("vphantom", "{...}", "empty box with the argument's height and depth only (zero width; an explicit \\item[...] label keeps plain text)"), ("uline", "{...}", "ulem underline: 0.4pt rule under the argument (single-line; needs ulem)"), ("underline", "{...}", "kernel text underline: TeXbook Rule 10 math-rule under an unbreakable hbox"), ("underbar", "{...}", "kernel text underline: Rule 10 rule like \\underline but content depth zeroed (fixed position)"), diff --git a/crates/compiler/supported/supported-latex.json b/crates/compiler/supported/supported-latex.json index a7e8f101c..c867e1904 100644 --- a/crates/compiler/supported/supported-latex.json +++ b/crates/compiler/supported/supported-latex.json @@ -245,9 +245,9 @@ {"name": "LaTeX", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \\TeX", "renders": true}, {"name": "LaTeXe", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "\\LaTeX, kern .15em, 2 and a text-style subscript varepsilon", "renders": true}, {"name": "rule", "mode": "text", "origin": "text_dispatch", "arguments": "[raise]{dimension}{dimension}", "description": "filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \\textwidth, \\linewidth, \\columnwidth", "renders": true}, - {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted", "renders": true}, - {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth)", "renders": true}, - {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width)", "renders": true}, + {"name": "phantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width, height and depth; nothing is painted (an explicit \\item[...] label keeps plain text, so the reserved width is lost there)", "renders": true}, + {"name": "hphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's width only (zero height and depth; an explicit \\item[...] label keeps plain text, so the reserved width is lost there)", "renders": true}, + {"name": "vphantom", "mode": "text", "origin": "text_dispatch", "arguments": "{...}", "description": "empty box with the argument's height and depth only (zero width; an explicit \\item[...] label keeps plain text)", "renders": true}, {"name": "thinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .16667em (math: thin muskip)", "renders": true}, {"name": "negthinspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern -.16667em", "renders": true}, {"name": "medspace", "mode": "text", "origin": "text_dispatch", "arguments": "", "description": "text kern .2222em", "renders": true}, diff --git a/crates/compiler/tests/references_and_figures.rs b/crates/compiler/tests/references_and_figures.rs index f7e6da016..13675406c 100644 --- a/crates/compiler/tests/references_and_figures.rs +++ b/crates/compiler/tests/references_and_figures.rs @@ -294,3 +294,50 @@ fn inserting_top_section_recomputes_and_matches_full_build() { assert!(output.contains(&expected)); } } + +#[test] +fn figure_caption_centering_measures_the_broken_line() { + // Fourth review, finding 3: the caption width must come from the real + // emitter (`inline_box`), not a hand-mirrored walk whose catch-all + // swallowed `LineBreak` and summed both lines into the centred width. + // A two-line caption's first (longest) line is centred on its own width. + use flashtex_compiler::layout::{text_width, Font, BODY_SIZE_PT, MARGIN_PT}; + let source = "\\begin{document}\\begin{figure}\\caption{AAAAAAAAAAAAAAAAAAAAAAAA\\\\B}\\end{figure}\\end{document}"; + let output = compile_full(source, LayoutConstraints::default()); + assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics); + let items = &output.pages[0].items; + let first = items.first().expect("caption must lay out items"); + let first_line: Vec<_> = items + .iter() + .filter(|item| item.baseline_y_pt == first.baseline_y_pt) + .collect(); + assert!(first_line.len() >= 2, "caption must break: {first_line:?}"); + assert_eq!(first.text, "Figure 1:"); + let last = first_line.last().expect("first line items"); + let measure = LayoutConstraints::default().measure_pt; + let left_gap = first.x_pt - MARGIN_PT; + let right_gap = + (MARGIN_PT + measure) - (last.x_pt + text_width(&last.text, BODY_SIZE_PT, Font::TimesRoman)); + assert!( + (left_gap - right_gap).abs() < 0.03, + "first caption line must be centred: left={left_gap} right={right_gap}" + ); +} + +#[test] +fn figure_caption_measurement_reports_reference_warnings_once() { + // Fourth review, finding 3 guard: measuring through the real emitter + // must not double-report diagnostics — the measurement's notes are + // dropped and only the real emission's survive. + let output = compile_full( + "\\begin{document}\\begin{figure}\\caption{A\\ref{missing}B}\\end{figure}\\end{document}", + LayoutConstraints::default(), + ); + let undefined = output + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.message.contains("undefined")) + .count(); + assert_eq!(undefined, 1, "one undefined-reference warning: {:?}", output.diagnostics); + assert!(output.pages[0].items.iter().any(|item| item.text == "??")); +} diff --git a/crates/compiler/tests/text_phantom.rs b/crates/compiler/tests/text_phantom.rs index 61e2c8099..ccd63fe3a 100644 --- a/crates/compiler/tests/text_phantom.rs +++ b/crates/compiler/tests/text_phantom.rs @@ -35,6 +35,16 @@ fn page_texts(source: &str) -> Vec { .collect() } +/// Total laid-out width of a single-line document: the last item's right +/// edge minus the first item's left edge. +fn line_total(source: &str) -> f64 { + let output = compile(source); + let items = &output.pages[0].items; + let first = items.first().expect("laid-out items"); + let last = items.last().expect("laid-out items"); + last.x_pt + text_width(&last.text, BODY_SIZE_PT, Font::TimesRoman) - first.x_pt +} + /// `(x_pt, baseline_y_pt)` of the first laid-out item whose text is `word`. fn position_of(source: &str, word: &str) -> (f64, f64) { compile(source).pages[0] @@ -376,3 +386,78 @@ fn phantom_reference_still_warns_when_undefined() { "phantom must not swallow the warning: {ghost_notes:?}" ); } + +#[test] +fn phantom_reserves_leading_interword_glue() { + // Fourth review, finding 1: TeX's `\hbox{ X}` reserves the leading + // glue, so `A\phantom{ X}B` must lay out exactly like `AX B` (the + // review's pdflatex oracle is 25.41672pt at 10pt; here the sibling + // render pins the same single-space geometry at this engine's metrics). + let source = "\\begin{document}A\\phantom{ X}B\\end{document}"; + let oracle = "\\begin{document}AX B\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + assert_eq!(line_total(source), line_total(oracle)); + assert_eq!(page_texts(source), ["A", "B"]); +} + +#[test] +fn phantom_reserves_leading_and_trailing_glue() { + // Fourth review, finding 1: `A\phantom{ X }B` reserves TWO spaces + // (pdflatex total 28.75005pt): one word space more than `AX B`. + let source = "\\begin{document}A\\phantom{ X }B\\end{document}"; + let oracle = "\\begin{document}AX B\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + let expected = line_total(oracle) + word_space(BODY_SIZE_PT, Font::TimesRoman); + assert!( + (line_total(source) - expected).abs() < 0.02, + "both spaces must be reserved: total={}, expected={expected}", + line_total(source) + ); + assert_eq!(page_texts(source), ["A", "B"]); +} + +#[test] +fn phantom_whitespace_only_reserves_a_single_space() { + // Fourth review, finding 1: `A\phantom{ }B` (whitespace-only) must + // lay out exactly like `A B` (pdflatex total 17.9167pt): exactly one + // space, not zero and not two. + let source = "\\begin{document}A\\phantom{ }B\\end{document}"; + let oracle = "\\begin{document}A B\\end{document}"; + assert!(messages(source).is_empty(), "{:?}", messages(source)); + assert_eq!(line_total(source), line_total(oracle)); + assert_eq!(page_texts(source), ["A", "B"]); +} + +#[test] +fn box_trailing_space_emits_no_empty_text_item() { + // Fourth review, finding 2: the shared `box_inlines` trailing-gap + // marker must not leak a `""` TextItem into the page item stream for + // box commands whose ink is kept (`\phantom` never shows it because + // its ink is discarded). + for source in [ + "\\usepackage{xcolor}\\begin{document}A\\colorbox{yellow}{X }B\\end{document}", + "\\begin{document}A\\underline{X }B\\end{document}", + ] { + assert!(messages(source).is_empty(), "{source:?}: {:?}", messages(source)); + let output = compile(source); + let items: Vec<_> = output + .pages + .iter() + .flat_map(|page| page.items.iter()) + .collect(); + assert!( + !items.iter().any(|item| item.text.is_empty()), + "{source:?} must lay out no empty-string item: {:?}", + items.iter().map(|item| item.text.clone()).collect::>() + ); + // The widths stay right: the trailing space still adds exactly one + // word space past the no-space sibling. + let nospace = source.replace("X }", "X}"); + let (bx_space, _) = position_of(source, "B"); + let (bx_plain, _) = position_of(&nospace, "B"); + assert!( + ((bx_space - bx_plain) - word_space(BODY_SIZE_PT, Font::TimesRoman)).abs() < 0.02, + "{source:?}: trailing space must add one word space: {bx_plain} -> {bx_space}" + ); + } +} diff --git a/docs/user/compiler.md b/docs/user/compiler.md index 3023f6aa0..e790b0f81 100644 --- a/docs/user/compiler.md +++ b/docs/user/compiler.md @@ -583,9 +583,9 @@ Canonical sources: | `\LaTeX` | | latex.ltx logo: L, kern -.36em, script-size A raised to the T height, kern -.15em, \TeX | | `\LaTeXe` | | \LaTeX, kern .15em, 2 and a text-style subscript varepsilon | | `\rule` | `[raise]{dimension}{dimension}` | filled rule box; pt/in/cm/mm/bp/dd/cc/pc/sp, em, ex, \textwidth, \linewidth, \columnwidth | -| `\phantom` | `{...}` | empty box with the argument's width, height and depth; nothing is painted | -| `\hphantom` | `{...}` | empty box with the argument's width only (zero height and depth) | -| `\vphantom` | `{...}` | empty box with the argument's height and depth only (zero width) | +| `\phantom` | `{...}` | empty box with the argument's width, height and depth; nothing is painted (an explicit \item[...] label keeps plain text, so the reserved width is lost there) | +| `\hphantom` | `{...}` | empty box with the argument's width only (zero height and depth; an explicit \item[...] label keeps plain text, so the reserved width is lost there) | +| `\vphantom` | `{...}` | empty box with the argument's height and depth only (zero width; an explicit \item[...] label keeps plain text) | | `\thinspace` | | text kern .16667em (math: thin muskip) | | `\negthinspace` | | text kern -.16667em | | `\medspace` | | text kern .2222em |