diff --git a/CHECKIN.md b/CHECKIN.md new file mode 100644 index 000000000..3586a2e21 --- /dev/null +++ b/CHECKIN.md @@ -0,0 +1,130 @@ +# CHECKIN — lane `phantom-review-fix-4`, slice 1 + +## 1. Merge-conflict resolutions (`git merge origin/main`, 4 files) + +All four conflicts are the same shape: this branch added text-mode +`\phantom` support while `origin/main` independently added kernel +`\textsuperscript`/`\textsubscript`, both extending the same `match` +sites / impl blocks. Every site now keeps **both** arms; verified by +`cargo check`, the `textscript` suite (8 passed, main's feature intact) +and the `text_phantom` suite (21 passed, this branch's feature intact). + +- `crates/compiler/src/incremental.rs` (`shift_inlines`): kept HEAD's + `Inline::Phantom` arm (map span + shift content) **and** main's + `Inline::TextScript` arm (map span + shift content). +- `crates/compiler/src/layout.rs`, `visit_inline_references`: kept HEAD's + `Inline::Phantom` arm (refs inside a phantom still warn) **and** main's + `Inline::TextScript` arm. +- `crates/compiler/src/layout.rs`, `emit`: kept HEAD's full + `Inline::Phantom` arm (unbroken-box measurement, overflow wrap check, + vertical extents, underline-depth reserve) **and** main's full + `Inline::TextScript` arm (local `\sf@size` resolution, mark-size emit, + raise/lower shift). +- `crates/compiler/src/parser.rs`: kept HEAD's `text_phantom` method + **and** main's `text_script` method (with its outer-size clearing). + Command dispatch already contained both (`phantom|hphantom|vphantom` + and `textsuperscript|textsubscript`); the `Inline` enum and + `supported.rs` inventory had auto-merged with both variants/entries. +- `docs/user/compiler.md`: took **main's side**, then regenerated via + `crates/compiler/scripts/render_supported_latex.sh` (which also + re-synced `supported-latex.json`, `coverage.md` and the Mac bundled + copy). Regenerated header reads **363 text-mode** (= main's 360 + this + branch's 3 phantom commands), 570 math-mode, 25 packages — both sides' + counts survive. `cargo test --test supported_latex` passes, so the + drift gate confirms the doc is current. + +No conflict markers remain anywhere (`git grep "<<<<<<<|>>>>>>>"` +empty; `git status` reports "All conflicts fixed"). + +## 2. Caption test vs real pdflatex behavior + +The fixture `\caption{AAAAAAAAAAAAAAAAAAAAAAAA\\B}` (24 A's, explicit +`\\`) was asserted to break into two lines with the first centred on its +own width. That never matches pdflatex: article.cls `\@makecaption` +measures the caption in an `\sbox` — restricted horizontal mode, so `\\` +(`\@xnewline`: unskip before, ignore spaces after) is glue, never a +break — and centres the **whole single line** when it fits `\hsize`. +Measured in this engine: single-line width ≈ 262.6pt (`Figure 1:` +43.668 + space 3 + 24 A's 207.936 + `B` 8.004 at 12pt) against a 468pt +measure — it fits, so pdflatex sets it on **one** centred line. + +Compiler fix (`crates/compiler/src/layout.rs`, `FigureCaption` arm): +measure the break-joined run (`caption_single_line`: drops top-level +`LineBreak`s, clears the following `space_before`, mirroring +unskip+`\ignorespaces`); if that single line fits the measure, emit it +whole on one centred line; otherwise keep the old paragraph path +(`\\` breaks, lines wrap from the margin). New helpers +`caption_single_line` / `clear_space_before` live next to +`caption_box_width`; captions without `\\` take a byte-identical path +to before. + +Test fix (`crates/compiler/tests/references_and_figures.rs`): +replaced `figure_caption_centering_measures_the_broken_line` with +`figure_caption_short_explicit_break_stays_on_one_centred_line` — +asserts one shared baseline, `B` glued to the A-run with zero break +width (line edges equal the joined oracles `\caption{AAAA…AB}` and, +for the spaced case, `\caption{A \\ B}` ≡ `\caption{AB}`), and whole-line +centering — plus `figure_caption_overwide_break_opens_at_the_margin` +for the overflow branch (breaks, opens at the margin, i.e. paragraph +mode). Probe (since removed) showed the old code emitting y=84 then +y=98.4 (two lines, first line offset for its own width) and the new +code emitting all three items at y=84 with `B` at 429.3 = A-run end +exactly, left/right gaps equal at 102.7pt. + +## 3. `place` empty-result callers + +`place`'s actual current contract (`crates/compiler/src/layout.rs:1005`, +`fn place(&mut self, text: String, size: f64, span: Span, font: Font, +space_before: bool)`): it always advances the cursor and reserves +extents, but pushes a `TextItem` **only when `text` is non-empty** +(round-3's no-empty-items change; `push_item` has the same early return). +So after `place("")` there is no new item to adjust. + +Audit of every `place`/`push_item` caller (12 `place` sites, 3 +`push_item` sites): the ONLY site that touches the just-placed item is +the footnote-mark raise in `crates/compiler/src/layout/footnotes.rs:279` +(the finding's `footnotes.rs:225` is a stale path — the file has always +lived at `layout/footnotes.rs`, and its line 225 is unrelated). That +site was already defensive since the file's creation and is now +load-bearing under the new contract: + +```rust +if let Some(item) = self.pages.last_mut().and_then(|page| page.items.last_mut()) { + item.baseline_y_pt = round2(item.baseline_y_pt - raise); +} +``` + +No change needed there. All other callers (`emit` text/reference/ +verbatim arms, heading numbers, verbatim-block lines, TOC entries, page +numbers) never index the just-placed item — verified arm by arm — so no +other site can panic or mis-adjust on an empty result. The `None` arm is +currently unreachable via public input (footnote numbers are never +empty), so it stands as cheap insurance, consistent with the new +contract. + +Tests: the empty-result path already had end-to-end coverage +(`box_trailing_space_emits_no_empty_text_item` in `text_phantom.rs`: +`place("")` via box-edge markers pushes no `""` item while geometry is +preserved). Added contract-level coverage alongside it: new unit test +`layout::tests::place_empty_text_pushes_no_item_but_still_advances` +(empty place pushes nothing on an empty page, cursor still advances, +guarded adjust finds nothing, non-empty place still pushes). It fails +under the old unconditional-push contract and passes now. + +## 4. Test commands and output (all from `crates/compiler`) + +- `cargo test --locked --test text_phantom` — **21 passed, 0 failed** + (includes round-3's `box_trailing_space_emits_no_empty_text_item`). +- `cargo test --locked --test references_and_figures` — **19 passed, + 0 failed** (rewritten one-line test, new overflow test, diagnostics-once + guard, and all pre-existing tests). +- `cargo test --locked --test footnote_counters` — 8 passed; + `--test footnote_long_argument` — 2 passed; + `cargo test --locked -p flashtex-compiler --lib + layout::tests::place_empty` — new unit test passes. +- `cargo test --locked --test supported_latex` — 8 passed (drift gate + green after regeneration); `--test textscript` — 8 passed. +- `cargo test --locked` (full crate suite) — **1042 passed, 0 failed, + 10 ignored (pre-existing `#[ignore]`s; none added), exit 0** across + all 85 test targets, including the 461 lib unit tests. Zero + regressions. \ No newline at end of file diff --git a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json index a3fb727ab..e17713be5 100644 --- a/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json +++ b/apps/mac/Sources/FlashTeXMac/Resources/supported-latex.json @@ -250,6 +250,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 (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/incremental.rs b/crates/compiler/src/incremental.rs index c479153f8..15683333f 100644 --- a/crates/compiler/src/incremental.rs +++ b/crates/compiler/src/incremental.rs @@ -708,6 +708,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::TextScript(t) => { map_span(&mut t.span, changes, deltas)?; shift_inlines(&mut t.content, changes, deltas)?; @@ -942,6 +952,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, Inline::Penalty { span, .. } | Inline::PagePenalty { span, .. } | Inline::Discretionary { span, .. } => *span, diff --git a/crates/compiler/src/layout.rs b/crates/compiler/src/layout.rs index 28f600485..0df0d457c 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(); @@ -993,30 +1002,54 @@ impl LayoutCursor { /// `\normalfont[#2 points]`. Placing then rewinds to `content_end`, /// discarding the previous item's eagerly reserved trailing space, /// exactly as `hspace` already does for `\hspace{}`. + /// + /// An empty `text` (a box-edge gap marker) still advances the cursor + /// but pushes no item, so a call places zero or one items — callers + /// that need the placed item must tolerate the empty result. fn place(&mut self, text: String, size: f64, span: Span, font: Font, space_before: bool) { if !space_before { 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(); 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); } @@ -1140,10 +1173,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 +1188,7 @@ impl LayoutCursor { return; } self.x += width; + self.content_end = self.x; } fn ensure_extents(&mut self, ascent: f64, descent: f64) { @@ -1935,18 +1973,36 @@ 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(); - self.x = MARGIN_PT + (self.constraints.measure_pt - width).max(0.0) / 2.0; - emit(self, content, body_size, Font::TimesRoman); + // `\@makecaption` (article.cls): the caption is first + // measured in an `\sbox` — restricted horizontal mode, so + // `\\` is glue, never a break — and centred whole on one + // line when that single line fits `\hsize`; only an + // over-wide caption is set as a paragraph (where `\\` does + // break). pdflatex therefore sets e.g. + // `\caption{AAAAAAAAAAAAAAAAAAAAAAAA\\B}` (~263pt at 12pt) + // on ONE centred line, not two: the explicit break joins + // with no width there (the sbox's `\@xnewline` unskips the + // glue before it and ignores spaces after it). + let flat = caption_single_line(content); + let single: f64 = self.caption_box_width(&flat, body_size); + if single <= self.constraints.measure_pt { + // Fits: one centred line with the breaks joined away. + self.x = + MARGIN_PT + (self.constraints.measure_pt - single) / 2.0; + emit(self, &flat, body_size, Font::TimesRoman); + } else { + // Over-wide: the paragraph path — `\\` breaks and lines + // wrap from the margin, as before. Centre the full + // invisible box: every inline width counts, including a + // phantom's reserved geometry (see `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); + 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); } Block::VSpace { .. } | Block::PageBreak | Block::VFill | Block::Penalty { .. } => {} @@ -2077,6 +2133,22 @@ impl LayoutCursor { self.constraints.font_size_pt } + /// 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 /// first baseline (a `tabular` entry or `@{}` text): a single unbroken /// line when `measure` is `None`, else a paragraph of that width. Returns @@ -2564,6 +2636,62 @@ fn visit_references(blocks: &[Block], visitor: &mut impl FnMut(&str, Span)) { } } +/// The caption run for `\@makecaption`'s one-line case (see the +/// `FigureCaption` arm): top-level `LineBreak`s (`\\`) joined away with no +/// width, and the space after each break cleared — the sbox's `\@xnewline` +/// unskips the glue before the break and ignores spaces after it, so +/// `\caption{A \\ B}` centres `AB`, not `A B`. Explicit glue nodes +/// (`HSpace`, `Kern`, `\quad`) are real boxes, not spaces, so they stay. +/// With no top-level break this is the content unchanged. +fn caption_single_line(content: &[Inline]) -> Vec { + if !content + .iter() + .any(|inline| matches!(inline, Inline::LineBreak { .. })) + { + return content.to_vec(); + } + let mut flat = Vec::with_capacity(content.len()); + let mut join = false; + for inline in content { + if matches!(inline, Inline::LineBreak { .. }) { + join = true; + continue; + } + let mut inline = inline.clone(); + if join { + clear_space_before(&mut inline); + join = false; + } + flat.push(inline); + } + flat +} + +/// Clear one inline's leading-space flag: every variant that carries +/// `space_before`, including the boxed wrappers whose flag lives on the +/// box. Variants without such a flag are left alone. +fn clear_space_before(inline: &mut Inline) { + match inline { + Inline::Text { space_before, .. } + | Inline::Math { space_before, .. } + | Inline::Reference { space_before, .. } + | Inline::CleverReference { space_before, .. } + | Inline::ThePage { space_before, .. } + | Inline::Footnote { space_before, .. } + | Inline::Marginpar { space_before, .. } + | Inline::Logo { space_before, .. } + | Inline::Rule { space_before, .. } + | Inline::Phantom { space_before, .. } + | Inline::Verbatim { space_before, .. } => *space_before = false, + Inline::ColorBox(b) => b.space_before = false, + Inline::Underline(u) => u.space_before = false, + Inline::TextScript(t) => t.space_before = false, + Inline::Graphic(g) => g.space_before = false, + Inline::Transform(t) => t.space_before = false, + _ => {} + } +} + fn visit_inline_references(inlines: &[Inline], visitor: &mut impl FnMut(&str, Span)) { for inline in inlines { match inline { @@ -2585,6 +2713,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), Inline::TextScript(t) => visit_inline_references(&t.content, visitor), _ => {} } @@ -2820,6 +2951,73 @@ 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, + // 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 + .as_deref() + .is_some_and(content_needs_box_extents), + _ => false, + }) +} + /// Hbox depth of underline content visible to a Core 14 layout: the /// pdflatex-measured descender depth ([`TEXT_DESCENDER_DEPTH_EM`] × size) /// when any text fragment holds a descender glyph from @@ -2845,6 +3043,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 { @@ -3161,6 +3424,72 @@ 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. + // 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; + } + 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 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 + // `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); + // 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(); + c.content_end = c.x + width; + c.x += width + word_space(size, font); + } Inline::TextScript(t) => { if !t.space_before { c.x = c.content_end; @@ -3234,6 +3563,38 @@ mod tests { .expect("expected item at source offset") } + #[test] + fn place_empty_text_pushes_no_item_but_still_advances() { + // `place`'s contract since the no-empty-items change: box-edge gap + // markers (`box_inlines`) are zero-width runs whose only job is the + // cursor advance — they must never reach the page as `""` items, so + // a `place("")` has no item to return and callers adjusting the + // just-placed item (the footnote mark raise) must handle the + // missing item explicitly (`if let Some`, not `expect`). + let mut c = LayoutCursor::new(LayoutConstraints::default()); + let span = crate::Span::new(0, 0); + let x_before = c.x; + c.place(String::new(), BODY_SIZE_PT, span, Font::TimesRoman, true); + assert!( + c.pages[0].items.is_empty(), + "empty text must push no item" + ); + assert!( + c.x > x_before, + "empty text must still reserve its glue advance" + ); + // The guarded shape every adjust-after-place caller uses: no panic + // on the empty page. + let adjusted = c + .pages + .last_mut() + .and_then(|page| page.items.last_mut()) + .is_some(); + assert!(!adjusted, "no item exists to adjust after an empty place"); + c.place("x".to_string(), BODY_SIZE_PT, span, Font::TimesRoman, true); + assert_eq!(c.pages[0].items.len(), 1); + } + #[test] fn inline_and_display_math_both_produce_positioned_items() { let source = "before $x$ after $$y$$ end"; diff --git a/crates/compiler/src/layout/footnotes.rs b/crates/compiler/src/layout/footnotes.rs index de4dcaf75..cff8d6b03 100644 --- a/crates/compiler/src/layout/footnotes.rs +++ b/crates/compiler/src/layout/footnotes.rs @@ -268,6 +268,15 @@ impl LayoutCursor { ) { let metrics = self.footnote_metrics(); if mark { + // `place` pushes nothing for an empty run, so the raise below + // must only touch the mark when one was actually placed — never + // whatever item came before it. A wrap inside `place` can move + // the mark onto a fresh page, so the page count is part of the + // observation, not just the item count. + let (pages, len) = ( + self.pages.len(), + self.pages.last().map_or(0, |page| page.items.len()), + ); self.place( number.to_string(), metrics.text_mark_size, @@ -275,9 +284,13 @@ impl LayoutCursor { Font::TimesRoman, space_before, ); - let raise = SUP2_RAISE_EM * self.constraints.font_size_pt; - if let Some(item) = self.pages.last_mut().and_then(|page| page.items.last_mut()) { - item.baseline_y_pt = round2(item.baseline_y_pt - raise); + let pushed = self.pages.len() > pages + || self.pages.last().is_some_and(|page| page.items.len() > len); + if pushed { + let raise = SUP2_RAISE_EM * self.constraints.font_size_pt; + if let Some(item) = self.pages.last_mut().and_then(|page| page.items.last_mut()) { + item.baseline_y_pt = round2(item.baseline_y_pt - raise); + } } } let Some(text) = text else { @@ -545,6 +558,24 @@ mod tests { ); } + #[test] + fn empty_mark_number_places_nothing_and_raises_nothing() { + // `place` pushes no item for an empty run, so a mark with no number + // must tolerate that empty result: no panic on an empty page, and a + // preceding item keeps its baseline instead of taking the mark's + // raise. + let span = Span::new(0, 1); + let mut cursor = LayoutCursor::new(LayoutConstraints::default()); + cursor.footnote("", span, true, None, true); + assert!(cursor.pages.iter().all(|page| page.items.is_empty())); + let mut cursor = LayoutCursor::new(LayoutConstraints::default()); + cursor.place("word".to_string(), 12.0, span, Font::TimesRoman, true); + let baseline = cursor.pages[0].items[0].baseline_y_pt; + cursor.footnote("", span, true, None, true); + assert_eq!(cursor.pages[0].items.len(), 1); + assert_eq!(cursor.pages[0].items[0].baseline_y_pt, baseline); + } + #[test] fn mark_is_a_raised_script_glued_to_the_word_and_note_sits_at_the_bottom() { let source = "Body word\\footnote{Note with $x$ math.} after."; diff --git a/crates/compiler/src/parser.rs b/crates/compiler/src/parser.rs index face7602a..8e361dda2 100644 --- a/crates/compiler/src/parser.rs +++ b/crates/compiler/src/parser.rs @@ -311,6 +311,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 @@ -1554,6 +1568,9 @@ pub(crate) const BUILT_INS: &[&str] = &[ "LaTeX", "LaTeXe", "rule", + "phantom", + "hphantom", + "vphantom", "thinspace", "negthinspace", "medspace", @@ -3539,6 +3556,10 @@ impl P<'_> { // the macro call is `}`, another command, or `, . ! ? ; : ' /`. "xspace" => self.xspace(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.text_mode_math_command(name, span), other => self.unsupported(other, span), } @@ -9195,6 +9216,64 @@ 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 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") => + { + 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 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", + 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) => { let body = self.class_size_pt.unwrap_or(crate::layout::BODY_SIZE_PT); pending = Some(apply_style(style, name, body)); @@ -9792,6 +9871,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, + }); + } + /// Kernel text-mode `\textsuperscript{...}` / `\textsubscript{...}` /// (latex.ltx `ltmisc.dtx` `\@textsuperscript` / `\@textsubscript`): /// always supported, no package needed. The argument is parsed as an @@ -11437,6 +11534,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/src/parser/colors.rs b/crates/compiler/src/parser/colors.rs index 040ff7544..3398663fb 100644 --- a/crates/compiler/src/parser/colors.rs +++ b/crates/compiler/src/parser/colors.rs @@ -227,6 +227,30 @@ 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 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() + .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 +261,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 +271,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 +279,44 @@ impl P<'_> { | Block::ListItem { content: inlines, .. } => inlines, _ => Vec::new(), }) - .collect() + .collect(); + // 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, + style: end_style, + space_before: true, + }); + } + inlines } /// `\color`/`\textcolor` at `index` of a flat token run: the index after diff --git a/crates/compiler/src/supported.rs b/crates/compiler/src/supported.rs index 76d4dfa50..a574b3405 100644 --- a/crates/compiler/src/supported.rs +++ b/crates/compiler/src/supported.rs @@ -327,6 +327,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 (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 a3fb727ab..e17713be5 100644 --- a/crates/compiler/supported/supported-latex.json +++ b/crates/compiler/supported/supported-latex.json @@ -250,6 +250,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 (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..cefa4691d 100644 --- a/crates/compiler/tests/references_and_figures.rs +++ b/crates/compiler/tests/references_and_figures.rs @@ -294,3 +294,119 @@ fn inserting_top_section_recomputes_and_matches_full_build() { assert!(output.contains(&expected)); } } + +#[test] +fn figure_caption_short_explicit_break_stays_on_one_centred_line() { + // Fourth review, caption finding: `\@makecaption` (article.cls) measures + // the caption in an `\sbox` — restricted horizontal mode, so `\\` is + // glue, never a break — and centres the whole single line when it fits + // `\hsize`. pdflatex therefore sets + // `\caption{AAAAAAAAAAAAAAAAAAAAAAAA\\B}` (24 A's, ~263pt at 12pt, well + // under the 468pt measure) on ONE centred line; the old test expected + // two lines with the first centred on its own width, which pdflatex + // never produces for this input. + use flashtex_compiler::layout::{text_width, Font, BODY_SIZE_PT, MARGIN_PT}; + let measure = LayoutConstraints::default().measure_pt; + for (broken, joined) in [ + ( + "\\begin{document}\\begin{figure}\\caption{AAAAAAAAAAAAAAAAAAAAAAAA\\\\B}\\end{figure}\\end{document}", + "\\begin{document}\\begin{figure}\\caption{AAAAAAAAAAAAAAAAAAAAAAAAB}\\end{figure}\\end{document}", + ), + // The sbox's `\@xnewline` unskips the glue before the break and + // ignores spaces after it, so a spaced break joins with no width + // either: `A \\ B` centres exactly like `AB`. + ( + "\\begin{document}\\begin{figure}\\caption{A \\\\ B}\\end{figure}\\end{document}", + "\\begin{document}\\begin{figure}\\caption{AB}\\end{figure}\\end{document}", + ), + ] { + let output = compile_full(broken, LayoutConstraints::default()); + assert!(output.diagnostics.is_empty(), "{broken:?}: {:?}", output.diagnostics); + let items = &output.pages[0].items; + // ONE line: every caption item shares the first baseline. + let y = items.first().expect("caption must lay out items").baseline_y_pt; + assert!( + items.iter().all(|item| item.baseline_y_pt == y), + "{broken:?} must set on one line: {items:?}" + ); + assert_eq!(items.first().map(|item| item.text.as_str()), Some("Figure 1:")); + // The break contributes no width: the joined oracle centres the + // same line edges. + let oracle = compile_full(joined, LayoutConstraints::default()); + assert!(oracle.diagnostics.is_empty(), "{joined:?}: {:?}", oracle.diagnostics); + let expected = &oracle.pages[0].items; + // The joined run is one item; the broken run splits it in two + // around the break. + assert_eq!(expected.len() + 1, items.len(), "{joined:?}: {expected:?}"); + let first = items.first().expect("caption items"); + let last = items.last().expect("caption items"); + let oracle_first = expected.first().expect("oracle items"); + let oracle_last = expected.last().expect("oracle items"); + assert!( + (first.x_pt - oracle_first.x_pt).abs() < 0.01, + "left edges must match: {} vs {}", + first.x_pt, + oracle_first.x_pt + ); + let right = last.x_pt + text_width(&last.text, BODY_SIZE_PT, Font::TimesRoman); + let oracle_right = oracle_last.x_pt + + text_width(&oracle_last.text, BODY_SIZE_PT, Font::TimesRoman); + assert!( + (right - oracle_right).abs() < 0.02, + "right edges must match: {right} vs {oracle_right}" + ); + // ... and that one line is centred whole. + let left_gap = first.x_pt - MARGIN_PT; + let right_gap = (MARGIN_PT + measure) - right; + assert!( + (left_gap - right_gap).abs() < 0.03, + "caption line must be centred: left={left_gap} right={right_gap}" + ); + } +} + +#[test] +fn figure_caption_overwide_break_opens_at_the_margin() { + // `\@makecaption`'s other half: a caption whose single line exceeds + // `\hsize` is set as a paragraph — `\\` breaks and lines wrap from the + // margin instead of centring. + let crowded = "Crowded ".repeat(30); + let source = format!( + "\\begin{{document}}\\begin{{figure}}\\caption{{{crowded}\\\\Tail}}\\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 baselines: Vec = { + let mut ys: Vec = items.iter().map(|item| item.baseline_y_pt).collect(); + ys.sort_by(|a, b| a.partial_cmp(b).expect("baselines")); + ys.dedup(); + ys + }; + assert!(baselines.len() >= 2, "over-wide caption must break: {items:?}"); + let first = items.first().expect("caption must lay out items"); + assert_eq!(first.text, "Figure 1:"); + assert!( + (first.x_pt - flashtex_compiler::layout::MARGIN_PT).abs() < 0.02, + "over-wide caption must open at the margin: x={}", + first.x_pt + ); +} + +#[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 new file mode 100644 index 000000000..ccd63fe3a --- /dev/null +++ b/crates/compiler/tests/text_phantom.rs @@ -0,0 +1,463 @@ +//! 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, word_space, 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() +} + +/// 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] + .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 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 = + "\\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}"); +} + +#[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:?}" + ); +} + +#[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 aaf923fd8..9a8225090 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: 360 text-mode and 570 math-mode command entries, 67 environments and 25 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: 363 text-mode and 570 math-mode command entries, 67 environments and 25 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. @@ -588,6 +588,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 (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 |