Skip to content

Show where an edit changes the PDF away from its own lines - #6

Merged
cu1ch3n merged 2 commits into
mainfrom
review-effects
Oct 30, 2026
Merged

cu1ch3n merged 2 commits into
mainfrom
review-effects

Conversation

@cu1ch3n

@cu1ch3n cu1ch3n commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

The PDF review located each edit with SyncTeX and diffed only the words printed there. A change that is a side effect of an edit was never shown: the uses of a redefined macro, equations and references renumbered after an inserted equation, a new bibliography entry. Changing \newcommand{\method}{…} showed only a source diff of the definition.

This PR shows those places under the edit that causes them.

  • A macro definition keeps a single change with Accept / Reject / Comment, its LaTeX diff, and Affects N places below it: each place as a typeset before/after crop with its page and section. The places have no buttons; deciding the edit decides them.
  • An edit that also prints something itself, such as an inserted equation, keeps its own crop and lists the places it changes elsewhere, such as the renumbered equations after it.
  • The first three places render with the round. Show N more renders the rest on request.

How it works

Each edit's own crop is unchanged: source diff, SyncTeX lookup, word diff at those spots. On top of that:

  1. Whole-document comparison (render/effects.ts). Both PDFs are compared in reading order, so pagination does not matter:
    • Printed lines are aligned first, then the words within lines that differ, as the engine already compares a section's rows. A whole line rarely recurs, so words in an inserted paragraph cannot pair with similar words pages away.
    • Page furniture is left out: a line at a page's top or bottom edge that, without the page's own number, is empty or recurs at that height on another page (page numbers, running heads).
    • Removed text that comes back elsewhere counts as moved, using git's --color-moved threshold of at least 20 alphanumeric characters.
  2. Causes, found by compiling (attribute() in core/publish.ts). A change outside every edit's crop needs a cause:
    • With one edit in the round, it is that edit. No extra builds.
    • An edit that prints nothing of its own, such as a macro definition, is compiled alone onto the accepted text. The changes that build makes are exactly what accepting the edit makes.
    • Changes still unexplained are traced by compiling each half of the other edits alone and recursing into the half that makes them. A change neither half makes needs edits from both halves and lists the group. The same happens if a variant fails to compile, or if the round's budget of 16 extra builds, shared with the single-edit builds above, runs out.
    • A round of ordinary text edits with no changes outside their crops needs no extra builds.
    • A rendering pass that follows variant builds reuses the first pass's cards instead of asking SyncTeX again.
  3. Variant builds are git trees of the accepted text with some edits applied (withUnits(), Repo.treeWith()), compiled by the existing Builder. No commits are created.
  4. Places (placesOf(), renderPlace()): changes with the same causes that lie within the engine's existing JOIN_GAP share one crop. A crop grows to every printed line it cuts through, so a cases display or a tall delimiter is never cut in half. Marks come from the whole-document diff, so a word hyphenated differently is not marked as changed.
  5. Lazy loading: GET /api/tasks/:name/rounds/:n/effects/:unit renders the remaining places into the round's folder and caches them. Papilot allows the route through its review proxy and serves the same thing for its comparison of uncommitted changes (/review/:projectId/api/changes/rounds/:n/effects/:unit).

The renderer worker now writes each request to its own file, since several requests can render into one round's folder.

Build dates. Every build of a day prints the same date: SOURCE_DATE_EPOCH is set to noon UTC of the local date (with FORCE_SOURCE_DATE) unless the user sets it, and it is part of the build cache key. Without this, a build cached on an earlier day differed from today's by \today, and that difference was attributed to some edit. \today shows the local date; \time prints 12:00.

Cost

Round Extra builds Measured on the 26-page example paper
One edit, or text edits only 0 whole-document comparison ≈ 0.7 s
Redefined macro plus other edits 1 per edit that prints nothing (within the limit of 16) —
Inserted equation plus another edit 2 (one per half) 13 s in total
\Prob redefined, equation inserted, sentence edited 3 21 s in total

Limitations

  • The comparison is textual. A preamble change that alters only colour or spacing, with the same words, is not detected.
  • A side effect that lands inside another edit's crop is marked there as well as listed under its cause.
  • A comparison that takes longer than five seconds is given up, and that round lists no such places (documents rewritten throughout).
  • A decision made while variant builds run makes the publish start over, as any decision during a build does today; the build cache keeps the repeat short.

Review follow-up

An independent review of the first commit found these problems; the second commit fixes them:

  • Page headers: each build guessed its page header separately, so a line at the top of a page could be dropped from one document but not the other. On the example paper, inserting one paragraph listed 19 places, of which only the table-of-contents page numbers were real. After the fix it lists 2, both real, and needs no variant builds.
  • Alignment: word-only alignment paired numbers in an inserted paragraph with section numbers pages later. It now aligns lines first.
  • Moved text: only one half of a moved run was dropped.
  • Dates: \today differed between builds made on different days (fixed by the build dates above).
  • Build limit: single-edit builds ignored the limit (20 macro edits used 20 extra builds; now at most 16), and layout-only edits were compiled alone needlessly.
  • Smaller fixes: the places route checks its parameters first and answers 404 for an unknown round. A place's section comes from its first changed word. A change across a column break gets one crop per column. Empty lazy results are cached. An edit needed together with another is named by its change number.

Tests

  • Unit tests in vendor/latex-review/tests/ts/effects.test.ts: moved runs, page furniture (including section numbers that open pages), attribution by halving with edits needed together, the shared build limit, build dates.
  • New end-to-end test in vendor/latex-review/tests/ts/end-to-end.test.ts:
    • A macro used on five pages: one change, five places, three rendered, two from more().
    • The same macro edit alongside a title edit and a text edit: only the macro edit gets the places.
  • tests/core.test.ts covers the new proxy route.
  • Locally: npm run typecheck, npm test, npm run build, the engine's npm run build and its full Vitest suite (119 tests) pass, as does the viewer's browser test (PAPILOT_REVIEW_BROWSER=1).

🤖 Generated with Claude Code

The review located each edit with SyncTeX and compared only the words
printed there, so side effects went unseen: the uses of a redefined
macro, equations and references renumbered after an inserted equation,
a new bibliography entry.

The engine now also compares the two PDFs word by word in reading order
and lists each change outside the edits' own crops under the edit that
causes it, with no buttons of its own. A round with one edit needs
nothing more. With several, an edit that prints nothing itself, such as
a macro definition, is compiled alone onto the accepted text, and the
changes that remain unexplained are traced by compiling halves of the
other edits, at most sixteen extra builds. The first three places of an
edit render with the round; the rest render when the reviewer asks.

Papilot's comparison of uncommitted changes passes the same variant
builds and serves the remaining places, and the review proxy allows the
new route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review found that the whole-document comparison reported changes that
were not there:

- Each build guessed its page header separately, so a line at the top of
  a page was left out of one document but kept in the other. Page
  furniture is now a line at a page's top or bottom edge that, without
  the page's own number, is empty or recurs at that height on another
  page.
- Words were aligned across the whole document, so a number in an
  inserted paragraph could pair with a section number pages later. The
  comparison now aligns printed lines first and words only within the
  lines that differ, as the engine compares a section's rows.
- Text that moved was dropped where it left but kept where it arrived.
  Removals and additions of a run now pair up.
- A build kept from an earlier day differed from today's by \today.
  Every build of a day now prints the same date through
  SOURCE_DATE_EPOCH, which joins the cache key.

Also: builds of edits that print nothing count against the limit of
sixteen variants; layout and position edits no longer count as edits
that print nothing; the pass after variant builds reuses the first
pass's cards; a comparison that takes longer than five seconds is given
up; a place's section comes from its first changed word, and a change
across a column break gets one crop per column. The places route checks
its parameters first, answers 404 for an unknown round and keeps the
hint when the builds are gone, and an edit needed together with another
is named by its change number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cu1ch3n
cu1ch3n merged commit 46c1aeb into main Oct 30, 2026
4 checks passed
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