Skip to content

render-pipeline: \enlargethispage, \clearpage float flush, \flushbottom after \pagebreak, \nobreakdash (#64; stacked on #569, needs #568+#583 re-pin) - #584

Draft
d-q222 wants to merge 2 commits into
mainfrom
agent/daniel-parent/page-control-followups
Draft

d-q222 wants to merge 2 commits into
mainfrom
agent/daniel-parent/page-control-followups

Conversation

@d-q222

@d-q222 d-q222 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What

Render-pipeline follow-ups to #569 for issue #64. The PR is stacked on #569, so review the commit after 686b4abb. It consumes #583 (itself stacked on #568).

  • \enlargethispage{<dimen>} / * (pagebuild::Enlarge):
    • The compiler's EnlargeThisPage parameter is placed where latex.ltx's \insert\@kludgeins{\vskip-<dimen>} lands. Between paragraphs, that is before the next block. Inside a paragraph, it is after the line that holds the command (typeset::enlarge_marks, using the new pagebuild::vlist_starts).
    • Both page loops (break_pages_regions, break_pages_inserts_regions) raise \pagegoal from that point on. The page is packed to the goal in force at its best break.
  • \clearpage flushes deferred floats (floatpage::paginate, Placer::clear_deferred):
    • After a page ended by a body \clearpage/\cleardoublepage (or a chapter/part start), \@startcolumn runs as for any page. Then \@doclearpage sends the queued and deferred floats to float pages.
    • The end-of-document flush now calls the same method.
    • One-column only. make_column and the column packing are untouched.
  • \flushbottom after \pagebreak:
    • A forced penalty the document wrote (\pagebreak, \penalty-10000, also \vadjusted out of a paragraph) is pagebuild::BARE_EJECT_PENALTY (-10001). The page builder treats it like any eject (§1005). The packer no longer assumes \newpage's \vfil for it, so the page is stretched. EJECT_PENALTY stays \newpage's.
    • A forced penalty before a heading, rule or picture ejects as \newpage does. Without this, compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583's Block::Penalty would lose \pagebreak\section.
  • \nobreakdash (adapter Item::Penalty { boxed_dashes }): no break after the dashes (from compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583's \nobreak). The word before them is hyphenated with the dashes counted as letters: latex.ltx sets \lccode-=\-, and there is no discretionary after a boxed hyphen.

This PR does not build against the current vendor/compiler pin, by design. It needs the re-pin past #568 and #583, like #569.

Why

pdflatex, TeX Live 2026, article 10pt, \pagestyle{empty}. Lines and pages were read from both PDFs with PyMuPDF.

Item pdflatex before (#568+#569) after
\enlargethispage{2\baselineskip} / * / {30pt} then 50 lines 48 + 2 lines 46 + 4 48 + 2
\enlargethispage{-5\baselineskip} after Line 10. 41 + 9 46 + 4 41 + 9
\enlargethispage{3\baselineskip} after Line 50. 46, 49, 5 46, 46, 8 46, 49, 5
[p] figure, \clearpage, Second. First. / Figure 1 / Second. First. / Second. / Figure 1 as pdflatex
three 8cm [t] figures, Line 31-33, \clearpage, Line 34-36 4 pages: 33 / Fig 1+2 / Fig 3 / 3 lines 3 pages: 33 / 4 / 2 as pdflatex
report/book [p] figure then \chapter float page before chapter 2 (3 pages) after chapter 2 (report 3, book 4 pages) 3 pages, float page before
\flushbottom, 30 lines, \pagebreak or \penalty-10000 last baseline 675 484 (natural) 675
\nobreakdash-- in pages 1xx--2xx no line ends with line 3 ends 113– as pdflatex
strongly\nobreakdash-minded ... strong- / ly-minded strongly-minded unhyphenated as pdflatex

Overlap check

Test results

Scratch export of this branch with vendor/compiler replaced by #583's crates/compiler, built with --features linebreak-skip. The repository's vendor/ is untouched.

cargo test --release --no-fail-fast (whole crate): 393 passed; 0 failed
cargo test --release --test page_para_control: test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
same test file on #568 + #569 unchanged: test result: FAILED. 11 passed; 4 failed
pdflatex probe harness, items 2-5: 18 of 19 match (before: 6 of 19, the controls); #569's 89 probes: 89 of 89 (before: 87 of 89)
cargo clippy --lib --tests: no finding on a changed line

New tests in tests/page_para_control.rs, each pinned to pdflatex:

  • enlargethispage_changes_the_goal_of_its_own_page
  • clearpage_flushes_deferred_floats_onto_float_pages
  • flushbottom_stretches_a_page_ended_by_pagebreak_not_newpage
  • nobreakdash_forbids_the_break_after_its_dashes

After the re-pin, re-run the whole suite in the real tree.

Not done

  • \pagebreak directly before a heading under \flushbottom still keeps natural glue: Block::Heading carries only eject_before. pdflatex stretches it (the one mismatched probe).
  • \enlargethispage* breaks like \enlargethispage. \@make@specialcolbox's squeeze (\pageshrink) is not modelled; no probe discriminated.
  • \enlargethispage in a document with floats (floatpage::paginate) or multicols is not applied.
  • The \clearpage float flush is one-column only (\@doclearpage's two-column branch is not run mid-document). Footnotes held at a mid-document \clearpage are not given their own page first.
  • \include: render-pipeline: floats numbered in \include reading order, LoF/LoT and \ref agree (stacked on #537 and #538) #578.

🤖 Generated with Claude Code

https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

…ty, discretionary and parameter nodes (#64; needs the compiler re-pin)

What changed:
- Paragraphs: Inline::Penalty -> a penalty item (a forced one ends a justified
  line; \linebreak/\nolinebreak move the space before them after the penalty);
  Inline::Discretionary -> a flagged break with its pre-break text (\- and
  \discretionary{pre}{}{}), no automatic hyphenation of the word (TeX 899);
  Inline::PagePenalty -> a \vadjust penalty after the line it lands on.
- Vertical list: Block::Penalty before a paragraph (\goodbreak, \filbreak with
  \vfil/\vfilneg, \pagebreak[n<4], \nopagebreak, \nobreak, \penalty); "pagebreak"
  is no longer a source-scanned paragraph split.
- Parsed::parameters read where each paragraph ends: \tolerance,
  \pretolerance, \emergencystretch, \hfuzz (\sloppy, \fussy, sloppypar),
  \widowpenalty, \clubpenalty, \interlinepenalty (\samepage); \raggedbottom/
  \flushbottom; Parsed::hyphenation added to the English patterns.
- Body \cleardoublepage in a two-sided document starts an odd page.
- adapter.rs: the group LineBreak uses line_break_inline (the skip_pt field).

What was run (scratch export, vendor/compiler replaced by the compiler branch,
--features linebreak-skip; repository vendor/ untouched):
- --test page_para_control: test result: ok. 11 passed; 0 failed
- same tests on origin/main's pipeline: test result: FAILED. 0 passed; 10 failed
  (before the cleardoublepage test was added)
- full cargo test --release: 384 passed; 5 failed (bundled_typewriter only: its
  files are under apps/, which the export leaves out; 5 passed on origin/main)
- 86 of 89 pdflatex probes match (enlargethispage x2, flushbottom after \pagebreak differ)

Next step: re-pin vendor/compiler past the compiler PR, then merge this.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
…ttom after \pagebreak, \nobreakdash (#64)

What changed:
- \enlargethispage{<dimen>} (and *): the compiler's EnlargeThisPage
  parameter becomes a pagebuild::Enlarge at the vertical-list position of
  latex.ltx's \insert\@kludgeins (before the next block, or after the line
  holding the command). Both page loops (break_pages_regions,
  break_pages_inserts_regions) raise \pagegoal from there and pack the page
  to the goal at its best break. vlist_starts reports each block's first
  list index.
- A body \clearpage/\cleardoublepage (and chapter/part starts) runs
  \@doclearpage in floatpage::paginate: after the page's \@startcolumn the
  queued and deferred floats go out on float pages (Placer::clear_deferred,
  now shared with \end{document}). One-column documents only.
- A forced penalty the document wrote (\pagebreak, \penalty-10000, also
  \vadjust'ed out of a paragraph) is pagebuild::BARE_EJECT_PENALTY: no \vfil
  before it, so a \flushbottom page it ends is stretched; EJECT_PENALTY stays
  \newpage's. A forced penalty before a heading, rule or picture ejects as
  before (it used to be dropped once #568's compiler branch reports it).
- \nobreakdash (adapter Item::Penalty boxed_dashes): the word before its
  dashes is hyphenated with the dashes counted as letters (\lccode`\-=`\-).

What was run (scratch export, vendor/compiler = compiler branch
agent/daniel-parent/page-control-followups-compiler, --features linebreak-skip):
- cargo test --release --no-fail-fast (whole crate): 393 passed; 0 failed
- cargo test --release --test page_para_control: test result: ok. 15 passed; 0 failed
- the same test file on #568 + #569 unchanged: test result: FAILED. 11 passed; 4 failed
- pdflatex probes for items 2-5: 18 of 19 match (before: 6 of 19); #569's 89 probes: 89 of 89
- cargo clippy --lib --tests: no finding on a changed line

Next step: vendor/compiler re-pin past #568 and the compiler follow-up PR.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
@d-q222

d-q222 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

daniel-parent independent review (subagent)

Verdict: APPROVE

Testing basis — read this before the findings below. #584 has not re-pinned vendor/compiler yet (its own PR body says so, and it's a draft). To make it buildable/testable at all, I did a LOCAL, NEVER-COMMITTED scratch re-pin inside ~/flashtex-wt/review-584 only, per the review request's own recipe (git archive origin/agent/daniel-parent/page-control-followups-compiler -- crates/compiler | tar -x --strip-components=2 -C crates/render-pipeline/vendor/compiler, matching vendor/VENDORING.md's documented "git archive <branch>:crates/compiler and nothing else"). This was reverted before finishing (git checkout -- .../vendor/compiler && git clean -fd .../vendor/compiler); git status --porcelain crates/render-pipeline/vendor now prints nothing (pasted at the end). Nothing here was pushed or committed, and none of it reflects #584's own (not-yet-done) re-pin work — read every finding below as "if #583 were re-pinned in as-is today," not as a statement about #584's actual current vendor state.

What was run

  • cargo build -p render-pipeline (default features) against the scratch-swapped vendor: failed, error[E0063]: missing field skip_ptin initializer offlashtex_compiler::parser::Inline --> src/adapter.rs:5680.
    • Root-caused, not hand-patched: adapter.rs's own line_break_inline() (L5670-5682) is a pre-existing, already-committed compatibility shim, #[cfg(feature = "linebreak-skip")] Inline::LineBreak { span, skip_pt: None } #[cfg(not(...))] Inline::LineBreak { span }, doc-commented "so the crate builds against a pinned compiler with or without the skip_pt field." skip_pt was added to Inline::LineBreak by an already-merged-to-main compiler PR (\\[<dimen>]'s optional argument, compiler: report \\[<dimen>]'s optional argument on Inline::LineBreak #478) that compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583's branch inherited by being rebased on current main; render-pipeline/Cargo.toml's linebreak-skip feature ([features] linebreak-skip = [], L106) just isn't flipped to default yet, which is exactly the step the crate's own comment anticipates for "once the pin carries skip_pt" (the same pattern VENDORING.md documents repeatedly for amsmath-inline/par-leading/math-class-override). This is unrelated compiler-side drift, not a page-control logic bug — confirmed by rebuilding with cargo build -p render-pipeline --features linebreak-skip, which succeeds cleanly (only pre-existing, unrelated dead-code/unused-variable warnings). All checks below use that flag (and, for the CLI, --features flashtex-render-pipeline/linebreak-skip); no tracked file was edited to get there, only a --features build flag.
  • cargo test -p render-pipeline --features linebreak-skip --no-fail-fast --test page_para_control: 15 passed, 0 failed (allowbreak_is_a_legal_break, cleardoublepage_starts_an_odd_page_when_two_sided, pagebreak_inside_a_paragraph_does_not_break_the_paragraph, hyphenation_exceptions_replace_the_patterns, discretionaries_break_with_their_pre_break_text_and_are_invisible_otherwise, flushbottom_stretches_a_page_ended_by_pagebreak_not_newpage, linebreak_and_a_forced_penalty_end_a_justified_line, nobreakdash_forbids_the_break_after_its_dashes, nobreak_nolinebreak_and_penalty_10000_forbid_the_break, clearpage_flushes_deferred_floats_onto_float_pages, raggedbottom_and_flushbottom_decide_the_page_glue, enlargethispage_changes_the_goal_of_its_own_page, widow_club_and_interline_penalties_are_read_from_the_document, sloppy_family_and_breaking_parameters_are_read_where_the_paragraph_ends, vertical_penalties_choose_the_page_break). These tests panic loudly (tests/common/mod.rs::lm_available) rather than silently skipping when Latin Modern isn't resolvable, and they didn't panic, so this suite genuinely exercised real rendering, not a no-op.
  • /Library/TeX/texbin/pdflatex -interaction=nonstopmode -halt-on-error, 3 passes each, TeX Live 2026, all scratch docs with \usepackage{lmodern} (to match the pipeline's own font substitution) built via flashtex build ... --font-dir apps/mac/Fonts --v2 <file>.json, geometry compared with PyMuPDF (fitz) and the compiler's own .aux \newlabel page numbers.

Findings

  1. \clearpage with 5 mixed [t]/[b]/[p]/[t]/[b] floats — input: 8 filler paragraphs, figT1[t], tabB1[b], figP1[p], figT2[t], tabB2[b], 8 more filler paragraphs, \clearpage, each float \labeled → pdflatex (via \newlabel in the .aux, stable across 3 passes, identical with or without lmodern): 3 pages total; figT1→p1, tabB1→p1, figP1→p2, tabB2→p2, figT2p3. This is itself worth citing precisely: real LaTeX does not flush floats in one single cross-class relative order — figP1 (declared 3rd) and tabB2 (declared 5th) share a page while figT2 (declared 4th) is pushed to the next one, because t/b/p are independent per-class queues and the float-page-building decision is made with whatever's queued at that moment, not a global FIFO. → actual (render-pipeline, --v2 JSON word stream per page): also 3 pages; figT1→p1, tabB1→p1, figP1→p2, tabB2→p2, figT2p2. Same total page count, same page-1 pair, same page-2 pair grouping (figP1+tabB2 share a page in both engines) — the one difference is which page absorbs the trailing t-float (figT2): pdflatex's page 3 vs. the pipeline's page 2. However, the pipeline packed noticeably more filler text per page than pdflatex did with the same lmodern fonts (all ~16 filler paragraphs landed on page 1 in the pipeline vs. spread over the whole 3-page document in pdflatex), a pre-existing page-density/line-count difference unrelated to this PR's diff (nothing here touches text metrics or \textheight), so I can't conclusively attribute the one-page offset on figT2 to a float-ordering defect in this PR versus that pre-existing density difference shifting where "page 3" begins. Recommend the PR add a page_para_control.rs-style controlled-line-count fixture (like its own enlargethispage_changes_the_goal_of_its_own_page, using filler(n,m)) for this exact mixed-placement scenario so the per-class-queue behavior is pinned precisely, independent of absolute density. Not a blocking finding as filed.

  2. \enlargethispage immediately followed by a float — input: filler(1,50) + \enlargethispage{2\baselineskip} (also *, {30pt}, {-5\baselineskip}, {3\baselineskip}) before more filler, from the PR's own tests/page_para_control.rs::enlargethispage_changes_the_goal_of_its_own_page → pdflatex ground truth (independently reproduced at a hand-built crossover point: 37 one-line filler paragraphs + [h] figure + trailing text, lmodern): without \enlargethispage, the trailing "End." text overflows to page 2 (2 pages total); with \enlargethispage{2\baselineskip}, the same trailing text stays on page 1 (1 page total) — a clean, page-count-level confirmation that \enlargethispage extends the current page's usable height for whatever follows, not just the float itself → actual: the PR's own internal test asserts exact per-page line counts for five separate cases ([48, 2] for both \enlargethispage{2\baselineskip} and its starred form, [48, 2] for {30pt} placed mid-filler, [41, 9] for a negative {-5\baselineskip} shrinking the page, [46, 49, 5] for a larger {3\baselineskip} spanning three pages) and it passes. That's strictly more precise evidence than an external black-box reproduction can offer (it isn't sensitive to paragraph-wrapping granularity). My own external attempt to reproduce the same effect through the CLI at a hand-tuned crossover filler count was inconclusive (both variants landed on 2 pages) — most likely because my hand-written filler paragraph doesn't wrap to a controlled, fixed line count the way the PR's own filler(n,m) helper does, so a ~24pt (2-baselineskip) shift can be swallowed by where an uncontrolled paragraph happens to wrap; I don't read this as contradicting the internal test, which is the stronger, precise evidence and passes.

  3. \nobreakdash-- inside a justified paragraph, narrow/tight enough that a break wants to fall at the dash — input: 40 repetitions of pages 1N--2N and (unmodified control) vs. the same with every -- replaced by \nobreakdash--, from the PR's own tests/page_para_control.rs::nobreakdash_forbids_the_break_after_its_dashes (asserts line 3 of the control ends "...pages 113–" and line 3 of the boxed version ends "...pages" with line 4 starting "113–213...") → pdflatex (independently reproduced, lmodern, PyMuPDF text extraction): exact match — control's 3rd extracted line ends '...and pages 113–'; boxed's 3rd line ends '...and pages' and its 4th starts '113–213 and pages...'. → actual: the PR's test encodes and passes this exact behavior (assert_eq! on both exact strings). No break falls after the dash in either the compiler's Inline::Penalty{value: INF_PENALTY} representation (per PR compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583's own change) or the render-pipeline's line breaker. Match, confirmed at both the source-citation level and independently against real pdflatex.

  4. \newpage vs. \pagebreak under \raggedbottom — input: 5 one-line filler paragraphs, then either \newpage or \pagebreak, then "Second page text.", \raggedbottom set, lmodern → pdflatex: byte-identical PDFs (same file size 28256 bytes, same last-glyph y-position on page 1 to the pt, no underfull/overfull warnings either way) — i.e., real LaTeX shows no observable difference between the two under \raggedbottom, because \raggedbottom sets the page's trailing glue to its natural size regardless of whether \newpage's extra \vfil (vs. bare \pagebreak's absence of one, per compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583) is present; the \vfil distinction that PR compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583 surfaced only bites under \flushbottom (finding 2's oracle above, and compiler: vertical \pagebreak is a bare penalty, amsmath \nobreakdash (#64; stacked on #568) #583's own flushbottom_stretches_a_page_ended_by_pagebreak_not_newpage). → actual: render-pipeline's two --v2 JSON outputs differ only in sources/byte-offset metadata (expected, since the two source files have different names/lengths) — after stripping that metadata, the rendered geometry (page count, every glyph's position, every caret/hit-rect) is identical between the two. Correctly reproduces the real, empirically-verified "no difference under raggedbottom" behavior rather than assuming one.

Cleanup confirmation

$ git -C ~/flashtex-wt/review-584 status --porcelain crates/render-pipeline/vendor
(no output)

d-q222 added a commit that referenced this pull request Sep 15, 2026
…s page

What changed:
The adapter lays an \include'd file's structural commands (\chapter, ...)
out before that file's next unit (paragraph, heading, rule, picture). A
file whose whole body is a captioned float has no unit, so its \chapter
(heading, page clear, number) was never laid out and the floats piled onto
one page ahead of later text. Likewise a trailing entry \chapter with only a
float after it was dropped: the end-of-document loop handled only marks,
page styles and \maketitle.
- An entry \input/\include consumed without a unit of the file it reads
  (found from the reading order) lays that file's commands out in place,
  then \include's closing \clearpage.
- A unit of an included file flushes up to the command that reads its
  file, not merely the next \input/\include.
- Included files read so far are finished whenever the next file is read,
  not only when the entry file resumes.
- The unit loop takes one last pass after the last unit, so commands after
  it (\chapter, \include, marks, \maketitle) go through the same code; the
  separate trailing loop is gone.
tests/include_float_only_chapters.rs pins pages, lines and baselines
against pdflatex for two and three float-only \include'd chapters, the same
under \includeonly (with and without trailing text), and single-file
float-only chapters.

What was run:
cargo test --release --test include_float_only_chapters
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
same test without the adapter change: test result: FAILED. 0 passed; 3 failed
chapter_in_included_file: test result: ok. 2 passed; 0 failed
float_body_oracle: test result: ok. 1 passed; 0 failed
float_graphics_oracle: test result: ok. 1 passed; 0 failed
float_notes_oracle: test result: ok. 1 passed; 0 failed
float_numbering_oracle: test result: ok. 1 passed; 0 failed
floats_oracle: test result: ok. 3 passed; 0 failed
include_float_lists: test result: ok. 1 passed; 0 failed
input_order_oracle: test result: ok. 1 passed; 0 failed
toc_oracle: test result: ok. 1 passed; 0 failed
cargo test --release -p flashtex-render-pipeline --no-fail-fast: passed 386, failed 0, ignored 3

Next step: a float right after a body \clearpage (no \include) still lands
on the old page; that break is carried by the next block's eject and is
left to the #569/#583/#584 page-break stack.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
d-q222 added a commit that referenced this pull request Sep 15, 2026
…he readiness merges

What changed:
- New docs/integration/repin-runbook.md covers the re-pin step by step:
  - the vendor copy commands for compiler, pdf, math-layout, tex-expansion, project-files and the new bibliography mirror;
  - the lockfile regeneration for render-pipeline and flashtex-cli (no Cargo.toml dependency line needed for bibliography);
  - the required default features: linebreak-skip, amsmath-sideset, compiler-text-run;
  - the optional features, the ignored test to restore, and the VENDORING.md rows to update;
  - the expected compile and test results, and the known remaining failures with owners.
- docs/integration/daniel-parent-preview.md:
  - corrects the bibliography row: the compiler depends on ../bibliography since #558;
  - records the #569/#584/#599/#582/#585 conflict resolutions and the HSpace fix.

What was run (scratch re-pin, vendor from c47e145, CARGO_BUILD_JOBS=4, never committed):
- render-pipeline cargo test --release --no-fail-fast: all binaries ok, 466 passed, 0 failed, 3 ignored
- page_para_control: test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
- flashtex-cli cargo test --release: ok. 31 passed; 0 failed / ok. 26 passed; 0 failed; 1 ignored
- amsmath TOTAL 59/59 within 0.5 bp; amssymb TOTAL 38/39 within 0.5 bp; tabular TOTAL 128/128 (words 0.5 bp, rules 0.1 bp); display-placement TOTAL 47/48 within 0.5 bp

Next step:
- Integration lane applies the runbook after the queue merges.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz
GoKubar added a commit that referenced this pull request Sep 15, 2026
… (#646)

* render-pipeline: tabular lengths keep their group scope; \small survives a float caption; @{} glue (#56)

What changed:
- Table lengths (\tabcolsep, \arrayrulewidth, \doublerulesep, \extrarowheight,
  and longtable's) are read as seen at the table: only assignments whose
  group ({...}, \begingroup, an environment) is still open count, in every
  form (\setlength{\x}{v}, \setlength\x{v}, \addtolength, \x=v). The old
  scan took the last brace-form \setlength anywhere in the file.
- A float-level size declaration (\small before \caption) is carried into
  later content runs, as \@caption's \normalsize is inside its own group.
- @{...} material keeps glue at both ends (@{\hspace{1em}}, @{\quad}).
- \hspace{<n>em} is set as <n> quads of the current font, not the
  compiler's fixed-size em.
- 17 new tabular corpus fixtures (107-123) with pdflatex references.

What was run:
- oracle.py check: main 117/123, this branch 123/123 (words 0.5 bp, rules 0.1 bp)
- cargo test --release (render-pipeline): 79 test binaries ok, 0 failed
- tabular_oracle: test result: ok. 1 passed; 0 failed
- lib: test result: ok. 140 passed; 0 failed; 2 ignored

Next step: compiler PR accepting table length assignments without the
"not implemented here" warning / unknown \tabcolsep error.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: report/book number figures and tables within the chapter

What changed:
- floats::number follows report.cls/book.cls: \thefigure and \thetable are
  \ifnum\c@chapter>\z@\thechapter.\fi\@Arabic\c@figure, reset by
  \@addtoreset{figure}{chapter}. \chapter* and book's \chapter outside
  \mainmatter step nothing; \appendix zeroes the chapter counter and makes
  \thechapter \@alph. Captions and \ref both use the value.
- FloatSpec.number is the printed string ("1.2"), not a bare counter.
- New pdflatex-pinned fixtures in fixtures/float-numbering (report chapters
  with \chapter*, report appendix, book front/main/back matter, an article
  control) and tests/float_numbering_oracle.rs: caption label, page and
  position within 1 bp, and the resolved \ref line.

What was run:
- cargo test --test float_numbering_oracle: test result: ok. 1 passed; 0 failed
- same test on origin/main sources: 17 mismatches (e.g. "Figure 1:" for
  pdflatex's "Figure 1.1:", chapter two's first figure printed "Figure 3")
- cargo test --test floats_oracle: ok. 2 passed; float_body_oracle: ok. 1
  passed; float_notes_oracle: ok. 1 passed; toc_oracle: ok. 1 passed;
  footnotes_oracle: ok. 1 passed; --lib floats: ok. 4 passed

Next step: open the GH-69 PR with the object-stream PDF sizing commit.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: size PDF graphics whose page tree is in object streams

What changed:
- graphics::probe_pdf falls back to flashtex_pdf::reader when the byte scan
  fails on a PDF with /ObjStm (PDF 1.5 object streams, pdfTeX's default
  \pdfobjcompresslevel 2). Before, every such PDF was image_unavailable and
  took no space. CropBox/MediaBox/Rotate handling is shared (page_info).
- New fixtures/float-graphics: a 72 dpi PNG and two pdfTeX object-stream
  PDFs (one /Rotate 90), made by make_fixtures.py; oracle.py pins box size and
  position from pdflatex. tests/float_graphics_oracle.rs checks
  width=\linewidth, scale, keepaspectratio, angle before/after a size key,
  \textwidth multiples, and the PDFs, within 1 bp, with no image diagnostic.
- Unit test graphics::tests::pdf_object_streams_are_read.

What was run:
- cargo test --test float_graphics_oracle: test result: ok. 1 passed; 0 failed
- same test without the graphics.rs change: 7 mismatches
- cargo test --lib graphics: test result: ok. 8 passed; 0 failed

Next step: [H] from the float package, then the PR.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: float.sty [H] sets the box exactly where it stands

What changed:
- FloatSpec::exact_here: with \usepackage{float}, a vertical-mode [H]
  float is not a float (\@float@HH). paginate splices \float@endH's
  \vskip\intextsep, the box and \vskip\intextsep at its marker, with TeX's
  interline glue (\lineskip against the line above; the next line's glue
  against the box's zero depth). It is never deferred or counted.
- [H] in the middle of a paragraph or on a two-column figure* now has its own
  diagnostic and falls back to h. [H] without the package keeps its old one.
- packages: float is implemented by the pipeline. \floatstyle, \restylefloat
  and \newfloat are still diagnosed by the compiler.
- fixtures/floats: make_fixtures.py adds 11-float-h and
  12-h-passes-deferred. oracle.py takes fixture names, so only those two
  were pinned; the other 10 sources and references are byte-identical.
  floats_oracle expects 12 fixtures, plus a diagnostics test for [H].

What was run:
- cargo test --test floats_oracle: test result: ok. 3 passed; 0 failed
  (11-float-h 2/2 pages, 13 anchors, max 0.002 bp; 12-h-passes-deferred
  3/3 pages, 9 anchors, max 0.003 bp)
- same fixtures on the pre-change sources: 26 mismatches
- cargo test --lib: test result: ok. 138 passed; 0 failed; 2 ignored
- float_body_oracle, float_notes_oracle, footnotes_oracle, toc_oracle,
  multicol_oracle, twocolumn_title_oracle: ok, 1 passed each;
  graphics_draft_demo: ok. 3 passed; float_numbering_oracle: ok. 1 passed;
  float_graphics_oracle: ok. 1 passed

Next step: open the GH-69 PR and post the verification table on #69.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: \chapter in an \include'd file is a chapter

What changed:
The adapter read structural body commands (\chapter, \part, \appendix,
book matter, marks/page styles, \addcontentsline) from the entry
document's source only. A \chapter in chapters/one.tex was set as a plain
paragraph on the current page: no page clear, no number, no marks, no
contents line. Included documents now get the same scan; their commands
are laid out before that document's next unit, and any left after its
last unit are laid out when the entry document resumes. \maketitle,
\noindent, contents lists and nested \input stay entry-only.

Corpus extended/project-book-include (#42): 5 pages -> 7 (pdflatex 7).

What was run:
cargo test --release (render-pipeline): passed 377 failed 0 ignored 3
test result: ok. 2 passed; 0 failed (chapter_in_included_file)
test result: ok. 1 passed; 0 failed (input_order_oracle)
test result: ok. 1 passed; 0 failed (toc_oracle)
test result: ok. 20 passed; 0 failed (flashtex-cli cli)

Next step:
\include's own \clearpage before/after (article: pdflatex 3 pages, we
set 1) and the missing CONTENTS running head in book front matter are
separate follow-ups.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* pdf: an ellipsis's periods no longer make every period extract as "…"

What changed:
- v2 export reduced cluster text to ToUnicode by first use. The pipeline sets
  `\textellipsis` as three periods in one cluster whose text is "…", so the
  period glyph was mapped to U+2026 for the whole font: the ellipsis
  extracted as "………" and every later sentence-final period as "…"
  (measured on "em dash… ellipsis." in OT1 and T1+lmodern documents).
- Only a one-glyph cluster now names its glyph's text. A glyph seen only
  inside clusters of several glyphs takes the character the font's own cmap
  maps to it (new `TrueTypeFont::char_for_glyph`, lowest code point), and the
  cluster text only when the cmap has none. Ligatures (`fi`, one glyph)
  still extract as their cluster text.
- tests/v2.rs: `ellipsis_periods_keep_the_period_in_to_unicode` (fails on
  main with `{28: "a", 88: "…"}`).

What was run:
- cargo test --release --test v2
  test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
- cargo test --release (crates/pdf, whole crate): 118 passed, 0 failed
- flashtex build --v2 + flashtex-pdf-exact from-v2 on the survey documents
  d4-punct-ot1 / d5-punct-t1-lm: "em dash……… ellipsis…" -> "em dash... ellipsis."

Next step:
- Re-pin crates/render-pipeline/vendor/pdf so `flashtex build` and Export
  PDF pick it up (vendor re-pins are the integration lane's).

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* math-layout: TeX's math font kerns (make_ord, tex.web §752) from the TFM lig/kern programs

What changed:
- tools/gen_cm_tfm.py emits each font's lig/kern program (lig_kern_starts,
  lig_kern, kerns) read from the real TFMs; cm_tfm.rs and ams_tfm.rs
  regenerated (additions only; existing char rows unchanged).
- tfm.rs: TfmFont::lig_kern walks the program as TeX does (first matching
  instruction; a ligature shadows a later kern).
- MathFontMetrics::ord_pair (default None) + CmMathMetrics impl: same-family
  check via symbol_slot, the scaled kern, and whether the font is a text font.
- layout.rs: make_ord appends the kern after an unscripted Ord character
  followed by an Ord..Punct character of the same family, and drops the
  italic correction in a text font (§755). Bins demoted by Rule 6 are not kerned.

What was run:
- cargo test --test math_font_kerns: test result: ok. 2 passed; 0 failed
- cargo test (math-layout): all suites ok (12, 4, 5, 28, 4, 2, 11 passed; 0 failed)

Next step: feature-gated forwarding in render-pipeline's TexMathMetrics, then HW1 and amsmath corpus measurement.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: an em is the quad of the font in force; table lengths follow macro invocations (#525 review)

What changed:
- `\hspace{<n>em}`, `\quad` and `\qquad` in running text are now set in
  quads of the font current where they are read. `Item::Quad` carries a
  style: the series/family come from the source declarations at the
  command, the size from the compiler's sizes of the text around it
  (`glue_size`). Before, the quad was the paragraph's base font, so
  `{\Large Ff\hspace{2em}Gg}` was 8.165 bp short and `{\bfseries ..}`
  2.989 bp short.
- The blank after `\hspace{..}`'s closing brace is an ordinary space.
  It was being skipped as if it followed a control word (3.3-3.9 bp).
- Size environments (`\begin{small}..\end{small}`, all ten sizes) give
  text, rules, kerns and tables their size unless a declaration made
  inside is still in force. A paragraph whose `\par` is read inside one
  gets that size's `\baselineskip`. The compiler's "environment is not
  implemented" warning for them is superseded.
- `length_at` applies the `\setlength`/`\addtolength`/`\<len>=` a user
  macro makes at the top level of its body (nested macros too, `#k`
  from braced arguments) at the invocation, with the invocation's group
  scope. An assignment that cannot be read (optional argument, unbraced
  argument) is left out and reported as a `table_limitation` warning.
- Corpus fixtures 124-126 (`\hspace{em}`/`\quad` under
  `\small`/`\normalsize`/`\Large`, `\bfseries` and size environments, in
  body text and tables, at 10/11/12pt), 127 (size-environment
  paragraphs) and 128 (macro `\setlength`/`\addtolength` before tables),
  with pdflatex references. Unit tests:
  table_lengths_follow_macro_invocations,
  size_environments_give_their_size_until_a_declaration,
  glue_takes_the_size_of_the_text_it_is_read_with.

What was run:
- python3 oracle.py check --render .../flashtex-render: TOTAL 128/128 (words 0.5 bp, rules 0.1 bp)
- cargo test --release (crates/render-pipeline): 79 test binaries, all "test result: ok", 0 failed
- lib: test result: ok. 143 passed; 0 failed; 2 ignored
- tests/tabular_oracle.rs: tabular_oracle: 128/128 fixtures match pdflatex; test result: ok. 1 passed; 0 failed

Next step: re-review of #525.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: math-font-kerns feature forwards math-layout's ord_pair (off until re-pin)

What changed:
- Cargo.toml: new off-by-default `math-font-kerns` feature; render-pipeline
  depends on vendor/math-layout (not crates/math-layout), which predates
  MathFontMetrics::ord_pair.
- mathtex.rs: TexMathMetrics::ord_pair asks the embedded CM programs for
  families 1-3 and the installed rm-lmr TFM for family 0.
- mathtext.rs: TextRunMetrics forwards, never kerning a \text handle.
- tests/math_font_kerns.rs (feature-gated): glyph origins of $r,a,b$,
  $f,P,Y.$, $f(x)$ $\Gamma,\Delta$ $x_1,x_2$ $V_{a}$ and HW1's display
  against pdflatex within 0.02 bp.

What was run (scratch copy of crates/render-pipeline with vendor/math-layout
replaced by this branch's crates/math-layout, feature on; vendor/ untouched):
- cargo test --release --features math-font-kerns --test math_font_kerns: test result: ok. 1 passed; 0 failed
- same test with ord_pair forced to None: fails, $r,a,b$ glyph 1 +0.608 bp, $f,P,Y.$ glyph 5 +3.633 bp
- amsmath corpus oracle.py check: TOTAL 59/59 within 0.5 bp (before and after)
- rank.py --only hw1: page 2 within 0.5 bp 203 -> 212; the 9 kern words (lines 560.9/585.4) from 0.60-0.61 bp to <=0.005 bp; no other word moved by more than 0.005 bp
- cargo build --release --bin flashtex-render against vendor/ (feature off): ok

Next step: re-pin vendor/math-layout (integration lane), then make math-font-kerns a default feature.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* math-layout: keep the ord_pair imports and impl clear of #470's lines

What changed: separate use lines for MathChar/OrdPair and CmMathMetrics::ord_pair moved after accent_sizes, so this branch merges cleanly with #470 (tag-textrun). No behaviour change.

What was run:
- cargo test (math-layout): every suite test result: ok, 0 failed

Next step: open the PR.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: number floats in the order the \include tree is read

What changed:
- adapter::reading_order walks \input/\include from the entry file
  (compiler lookup: path, then path.tex; the preamble's last \includeonly;
  cycles and unknown files read nothing) into ordered byte ranges.
- floats::number steps chapter/appendix/matter and the figure/table
  counters over those ranges, so an entry-file float after an \include
  whose file has its own \chapter gets 2.1, not 1.2. A float of a file
  never read (\includeonly-excluded) has no number, label, spec or .lof
  line.
- LoF/LoT entries take the float's own number (report pre-chapter float
  is "1", not "0.1") and merge in reading order.
- Float markers in paginate are placed by reading order, so an entry
  float after an included chapter follows that chapter's blocks.
- \include issues its \clearpage before the file and after it.
- Fixtures 05 (report, two files), 06 (book, \include + \input +
  appendix include), 07 (report, \includeonly) with pdflatex references;
  oracle.py handles multi-file projects; tests/include_float_lists.rs pins
  LoF/LoT/\ref against pdflatex's .lof/.lot/.aux.

What was run:
cargo test --release --test float_numbering_oracle
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo test --release --test include_float_lists
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo test --release --test toc_oracle
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo test --release --test floats_oracle
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo test --release --lib reading_order
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 140 filtered out

Next step: full crate run, then PR stacked on #537 and #538.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* math-layout: math ligatures in make_ord and kerns/ligatures inside text runs (tex.web §752-§753)

What changed:
- `OrdPair` carries the pair's ligature instruction (`OrdLigature { op, ch }`);
  `CmMathMetrics::ord_pair` reports cmr's `=:` ligatures (ff, fi, fl, ffi,
  ffl, and the quote/dash/Spanish marks) as Unicode text characters, and
  `text_glyph` boxes those characters from their OT1 slots.
- `layout`: the first pass runs `make_ord` over the list before boxing. A
  ligature rewrites the list as §753 does: `=:` merges the pair (the ligature
  takes the right atom's scripts), `=:|`/`|=:` replace one side, `|=:|`
  inserts, and the pair is retried unless the op is a `>` form. So
  `$\mathrm{f}\mathrm{i}$` is one fi glyph and `$\mathrm{f}\mathrm{i}^2$` is fi^2.
- `make_text` (`Nucleus::Text`, the multi-character `\mathrm{..}` and
  operator runs) applies the same program between its characters: the font
  kerns (`AV`, `Tr`, `Wa`) and ligatures (`ff`, `ffi`), last italic kept.
- tests/math_font_kerns.rs: 19 new widths pinned to pdfTeX `\wd0`, plus a
  synthetic program exercising every ligature form on both paths.

What was run:
cargo test --manifest-path crates/math-layout/Cargo.toml --test math_font_kerns
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
cargo test --manifest-path crates/math-layout/Cargo.toml
test result: ok. 12 / 4 / 5 / 28 / 4 / 3 / 2 / 11 passed; 0 failed
before this change the new widths fail, e.g. $\mathrm{AV}$ 15.13890pt vs pdfTeX 14.02777pt

Next step: forward ligatures through render-pipeline's TexMathMetrics and
one-character \mathrm groups; measure the pipeline against pdflatex.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: math ligatures and one-character alphabet kerns behind math-font-kerns

What changed:
- `TexMathMetrics::ord_pair` reads family 0's instruction straight from the
  installed rm-lmr TFM program (`Tfm::pair_program`, no text-mode boundary
  handling) and reports its ligatures, so `make_ord` forms them;
  `text_glyph` boxes a formed ligature (U+FB01 ...) from its OT1 slot.
- A one-character math alphabet pair of the same alphabet
  (`\mathbf{T}\mathbf{o}`, `\mathit{A}\mathit{V}`) is kerned from that
  alphabet's text-font TFM. Ligatures there are not formed (no character
  addresses a ligature slot of those fonts).
- `typeset`: a one-letter/digit `\mathrm{X}` is math-layout's `TextChar`
  (TeX §1186), so it joins its neighbour. All behind `math-font-kerns`.
- math-layout `cm::ligature_char` shared by both providers.
- tests/math_ligatures.rs pins glyph origins against pdflatex (lmodern).

What was run (scratch copy, vendor/math-layout := this branch's crate):
cargo test --release --features math-font-kerns --test math_ligatures
test result: ok. 1 passed; 0 failed
  negative control on #560's overlay: fails, "$x\mathrm{A}\mathrm{V}x$: glyph 2 at x 140.215 bp, pdflatex 139.003 (+1.212)"
cargo test --release --no-fail-fast --features math-font-kerns (whole crate): 377 passed; 0 failed; 3 ignored
oracle.py check: TOTAL 59/59 within 0.5 bp (before and after, per-fixture lines identical)
rank.py --only hw1: page 2 within 0.5 bp 212 of 215, pages 1 and 3 unchanged (same as #560)
worktree, feature off: cargo check --release --tests ok

Next step: open the PR stacked on #560.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* math-layout: keep the new layout.rs imports clear of #470's lines

What changed: the OrdLigature/SizeClass/Cow imports move to a block of their
own, so #560's import lines stay byte-identical and #470 still merges cleanly.

What was run:
git merge-tree --write-tree HEAD origin/agent/daniel-parent/tag-textrun: no conflict
cargo test --manifest-path crates/math-layout/Cargo.toml
test result: ok. 12 / 4 / 5 / 28 / 4 / 3 / 2 / 11 passed; 0 failed

Next step: open the PR stacked on #560.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: a chapter whose body is only a float still clears its page

What changed:
The adapter lays an \include'd file's structural commands (\chapter, ...)
out before that file's next unit (paragraph, heading, rule, picture). A
file whose whole body is a captioned float has no unit, so its \chapter
(heading, page clear, number) was never laid out and the floats piled onto
one page ahead of later text. Likewise a trailing entry \chapter with only a
float after it was dropped: the end-of-document loop handled only marks,
page styles and \maketitle.
- An entry \input/\include consumed without a unit of the file it reads
  (found from the reading order) lays that file's commands out in place,
  then \include's closing \clearpage.
- A unit of an included file flushes up to the command that reads its
  file, not merely the next \input/\include.
- Included files read so far are finished whenever the next file is read,
  not only when the entry file resumes.
- The unit loop takes one last pass after the last unit, so commands after
  it (\chapter, \include, marks, \maketitle) go through the same code; the
  separate trailing loop is gone.
tests/include_float_only_chapters.rs pins pages, lines and baselines
against pdflatex for two and three float-only \include'd chapters, the same
under \includeonly (with and without trailing text), and single-file
float-only chapters.

What was run:
cargo test --release --test include_float_only_chapters
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
same test without the adapter change: test result: FAILED. 0 passed; 3 failed
chapter_in_included_file: test result: ok. 2 passed; 0 failed
float_body_oracle: test result: ok. 1 passed; 0 failed
float_graphics_oracle: test result: ok. 1 passed; 0 failed
float_notes_oracle: test result: ok. 1 passed; 0 failed
float_numbering_oracle: test result: ok. 1 passed; 0 failed
floats_oracle: test result: ok. 3 passed; 0 failed
include_float_lists: test result: ok. 1 passed; 0 failed
input_order_oracle: test result: ok. 1 passed; 0 failed
toc_oracle: test result: ok. 1 passed; 0 failed
cargo test --release -p flashtex-render-pipeline --no-fail-fast: passed 386, failed 0, ignored 3

Next step: a float right after a body \clearpage (no \include) still lands
on the old page; that break is carried by the next block's eject and is
left to the #569/#583/#584 page-break stack.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: \check@icl italic correction and nested list closing skips (display-placement 35/36)

What changed:
- An upright text font command (\textbf, \textup, \texttt, \emph in italic
  text, ...) and amsmath's \eqref (\textup{\tagform@{..}}) put the italic
  correction of the character before them under the interword space
  (latex.ltx \check@icl -> \maybe@ic -> \sw@slant). Fixture 17's `(2.1).`
  was 0.784 bp left (the `f` of "Ref", kern 0.7922 pt).
- A list's closing \addvspace\@topsepadd uses the \topsep of the level that
  closes (was always level 1), with that list's own \partopsep flag; lists
  closing together and a following \item's \addvspace\itemsep keep the
  larger natural skip instead of the sum. Fixture 20's `2. Outer again` was
  7.97 bp low.
- A paragraph right after \end{itemize}/\end{enumerate} with no blank line
  is not indented (\@endpe), as after center/quote.
- Pins: tests/text_command_left_italic_correction.rs,
  tests/nested_list_end_skips.rs (pdflatex numbers; all four fail on main
  by -0.791, +7.970, -1.993, +3.985 bp); display_placement.rs PASSING adds
  17 and 20; baseline-render-pipeline.json refreshed.

What was run:
- display-placement oracle.py check: TOTAL 35/36 within 0.5 bp (main: 33/36;
  15 still fails on the rich \tag, owned by #470/#585)
- amsmath_corpus oracle.py check: TOTAL 59/59 within 0.5 bp
- cargo test --release -p flashtex-render-pipeline (all targets): 81 test
  binaries, 379 passed, 0 failed, 3 ignored

Next step: fixture 15 lands with #585 plus the math ToUnicode lane.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: math minus, asterisk and phi extract as the right Unicode

What changed:
- Math '-' extracts as U+2212 MINUS SIGN and '*'/\ast as U+2217 ASTERISK
  OPERATOR (both were ASCII); \phi/\varphi now extract as U+03D5/U+03C6
  (they were swapped). Glyph selection and positions are unchanged.
- Tests assert semantically correct Unicode, not pdflatex parity: pdflatex
  without the cmap package extracts cmex/cmsy glyphs as artefacts
  (\sum -> P, \mapsto -> 7->), which are deliberately not copied.

What was run:
- render-pipeline math targets: 68 passed, 0 failed
- render-pipeline lib: 138 passed, 0 failed, 2 ignored
- crates/pdf: all suites passed; git diff --check: passed

Next step:
- independent review.

Implementation-Agent: codex gpt-5.6-luna (max)
Commit-Executor: daniel-parent (committed Codex's working tree; sandbox blocked its git index)
Lane-Owner: daniel-parent (mac-m5pro-dq222)
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: keep only the four semantic math extraction fixes

What changed:
- The previous commit accidentally shipped the first-pass table that copied
  pdflatex's cmex/cmsy extraction artefacts (\mapsto -> 7->, \Longrightarrow
  -> =>, \circ -> bullet, \setminus -> ASCII backslash, ...). This commit
  applies the corrected working tree: extraction_text only maps '-' ->
  U+2212, '*' -> U+2217 and swaps \phi/\varphi; every other glyph keeps
  main's text. Tests assert semantically correct Unicode.

What was run:
- render-pipeline math targets and lib (see next comment on #597)

Next step:
- re-run tests; update #597.

Implementation-Agent: codex gpt-5.6-luna (max)
Commit-Executor: daniel-parent (staging slip in the previous commit)
Lane-Owner: daniel-parent (mac-m5pro-dq222)
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: long arrows extract as their drawn join pieces

What changed:
- The long arrows move out of common_math_glyph_runs_use_semantic_unicode
  into long_arrows_extract_as_their_drawn_pieces (option C from #536).
- A two-glyph join (#536's drawing) must extract as `=⇒` / `−→`, matching
  pdfTeX's own PDF (`x =⇒y −→z`, pdflatex + PyMuPDF). While main still draws
  one Latin Modern Math glyph, the text is the single `⟹` / `⟶`.
- A comment records that one `⟹` for two drawn glyphs needs PDF /ActualText
  marked content, a future crates/pdf change outside this PR.

What was run:
- math_symbols (debug): test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s

Next step:
- Merge into agent/daniel-parent/integration-preview and rerun math_symbols there.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* oracles: a math minus sign aligns with the references' hyphen-minus

What changed:
- tools/visual-oracle/rank.py `norm` folds U+2212 MINUS SIGN into U+002D
  before alignment. pdfTeX's own PDFs extract a math minus as U+2212
  (PyMuPDF on pdflatex's 19-vmatrix.pdf: "= ad −bc"; 28-xrightarrow.pdf:
  "f−→B", "h←−"), which is what this branch now emits. The pinned references
  read "-" only because pdftext's glyph-name table maps `minus` to "-". The
  references and FlashTeX are both right; the word matcher was not.
- amsmath_corpus/oracle.py `regroup` orders coincident glyphs (the \relbar
  and arrow head of \xrightarrow) with the same fold, so `−→` sorts like the
  references' `-→`.
- test_rank.py: a minus sign aligns with a hyphen-minus.

What was run:
- amsmath oracle, this branch's flashtex-render: TOTAL 58/59 after the norm
  fold alone (28-xrightarrow order tie), TOTAL 59/59 within 0.5 bp with both
- amsmath oracle, main's flashtex-render (36fe7ec): TOTAL 59/59 within 0.5 bp
- amssymb oracle, main and this branch: TOTAL 38/39 within 0.5 bp (both;
  32-braces-narrow fails identically on main)
- tabular oracle, main and this branch: TOTAL 106/106 (words 0.5 bp, rules 0.1 bp)
- display-placement oracle, main and this branch: TOTAL 33/36 within 0.5 bp (identical rows)
- python3 -m unittest discover -s tools/visual-oracle -p 'test_*.py': Ran 14 tests, OK

Next step: merge into the integration preview (#609) and rerun there.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* visual-oracle: candidate word text slices cluster byte ranges as bytes

What changed:
- rank.v2_words sliced the glyph run's Python str with the display list's
  cluster ranges, which are UTF-8 byte offsets. After the first non-ASCII
  character in a run (`∈`, and now this branch's U+2212 minus) every later
  glyph got its neighbour's text: `|f(x_k) − f(y_k)|` read as words `−f`,
  `(y` instead of `−`, `f(y`, and `x ∈ K` left an empty-text word. The
  sweep-2 "apparent regressions with identical pixels" (inline-math p1/p2,
  math-sheet p2, hw1 p1, conf-paper p2) were this. The amsmath oracle's own
  reader already encoded before slicing.
- test_rank.py: the v2 fixture helper writes byte offsets; a run with
  `−` and `∈` keeps its word text.

What was run (tools/visual-oracle/rank.py, all 22 real-world fixtures, same
flashtex-pdf-exact; aligned words summed over every page):
- old rank.py: main 36fe7ec 15285, preview 5d1eb06 15253
- this rank.py: main 15330, preview 15326
- pages whose aligned/within-0.5 counts differed main vs preview only
  through text (conf-paper p2, hw1 p1, inline-math p1/p2, math-sheet p1/p2,
  ps-calculus p2/p3, twelvept-plain p2, lecture-notes p2) are now identical
- amsmath 59/59, amssymb 38/39, tabular 106/106, display-placement 33/36
  (main) and 35/36 (preview): unchanged by this commit
- python3 -m unittest discover -s tools/visual-oracle -p 'test_*.py': OK

Next step: merge into the integration preview (#609).

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* render-pipeline: read each table length once per adapt call, not per table (#525 review, #623)

What changed:
- `length_at_checked` scanned the source before every table (a CmdScan of
  the prefix, plus a `group_open_between` rescan per assignment) for each
  of the four tabular lengths and the five longtable lengths. On a
  document with many tables that is quadratic or worse: 800 tables spent
  1.6 s in it. It is the same class of bug #623 fixed in
  `split_at_page_breaks`.
- Within an adapt call (`MacroDefsScope`) each length is now indexed once
  per document: `length_assignments` collects every `\setlength`/
  `\addtolength`/`\<name>=` and assigning macro invocation of the whole
  source once, `GroupTokens` lexes the group depth once (the tokens
  `group_open_between` counts, with the next-lower-depth token of each),
  and `LengthIndex` links each assignment to the last earlier one still in
  force at its end. A table binary-searches its byte offset and walks that
  chain past closed groups. The value and `unresolved` flag are folded in
  the same order as the scan, so the floats are bit-identical.
- Outside an adapt call, at a byte inside a control word, or for a document
  whose assignments do not form a chain (an assigning macro in another's
  argument, an assignment ending inside a comment), the old scan runs
  unchanged as `length_at_scan`.
- The indexes live in a new `lengths` field of `MacroDefsEntry`, placed
  before `index` so #623's `setlengths` field merges without conflict
  (`git merge-tree` of this commit with #623's head is clean).

What was run:
- `cargo test --release --lib -- table_length`: test result: ok. 5 passed; 0 failed
  - table_length_index_matches_the_prefix_scan: the indexed lookup equals
    the scan at every byte of 8 hand-written and 300 generated sources,
    for tabcolsep/arrayrulewidth/LTpre (both index and fallback paths hit).
  - table_lengths_scale_linearly_with_the_number_of_tables:
    before: 200 tables 41.2ms, 400 tables 242.7ms, 800 tables 1.637s (panicked: not linear)
    after:  200 tables 393µs, 400 tables 717µs, 800 tables 1.42ms
- flashtex-render end to end, generated doc, best of 3 (before -> after, v2 identical):
  200 tables 0.16s -> 0.08s; 400 tables 0.71s -> 0.22s; 800 tables 4.05s -> 0.73s
- v2 JSON + compile_result of all 128 tabular_corpus fixtures and 22
  fixtures/real-world documents, head 99defd5 vs this commit: `diff -rq` IDENTICAL (150/150)
- `oracle.py check`: TOTAL 128/128 (words 0.5 bp, rules 0.1 bp)
- `cargo test --release` (render-pipeline): exit 0, 79 test binaries, 383 passed; 0 failed; 3 ignored

Next step: merge #623 after #525 (or the reverse); the rest of the
remaining 800-table time is #623's `split_at_page_breaks` fix.

Implementation-Agent: claude-opus-5 subagent of daniel-parent
Commit-Executor: daniel-parent subagent
Claude-Session: https://claude.ai/code/session_012c9XLkHjePPGBuarrmE2mz

* integration: #639's layout_cache_keys test constructs #525's Item::Quad with its style field

Main's tests/layout_cache_keys.rs (#639) predates #525's Item::Quad
{ em, style }; the test's shared TextStyle fills the new field.

Implementation-Agent: fable-integration-rp
Commit-Executor: fable-integration-rp (direct; Cursor usage limit)

* integration: a file-tail float keeps its blank-line mask; include_float_lists expects #608's joined paragraph

Two #578 x #608 interactions on the combined head:

- floats::mask (#608) turned a float-only line into a % comment; for a
  float at the very tail of an \included file that left the paragraph
  open at EOF, and the compiler joined it with the entry document's
  next text, so #578's closing \clearpage landed after the merged
  paragraph (4 pages instead of pdflatex's 5). A float with nothing
  but blanks/comments after it now keeps the blank-line mask; entry
  documents are unaffected (\end{document} follows their floats).
- tests/include_float_lists.rs (#578) expected "Refs ..." on its own
  line; under #608 the [h] floats stay inside the paragraph (pdflatex
  behaviour), so the refs share the paragraph's line.

Implementation-Agent: fable-integration-rp
Commit-Executor: fable-integration-rp (direct; Cursor usage limit)

* integration: restore #543's entry-document chapter starts for \thelstlisting

The #578 delta removed the chapter_starts push together with the
chapter-based figure numbering it replaced, but #543's listing numbers
(list_blocks' Lol entries and listings::apply captions) still come from
chapter_numbers over those starts (toc_oracle fixture
37-report-lstlistoflistings).

Implementation-Agent: fable-integration-rp
Commit-Executor: fable-integration-rp (direct; Cursor usage limit)

---------

Co-authored-by: d-q222 <279808976+d-q222@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant