Conflicts:
- crates/render/src/inspect.rs: this branch's reply-linking methods and main's NoRotate
rectangles (#3016) both go in the Annotation impl; the annotation reader takes this
branch's loop with main's no_rotate field.
- crates/automation/src/comments.rs: this branch's HashMap import, main's import list.
- ATTRIBUTION.toml: each catalogue's source note keeps main's secured-document clause and
this branch's reply-count clause; ATTRIBUTION.md regenerated (cargo xtask assets --write).
- The 33 catalogues: main's new rows, then this branch's reply-count row.
main's #3016 tests build Annotation field by field; they now set parent and grouped.
Conflicts: optimize_ui.rs imports (this branch's RichText, main's Compatibility; both kept)
and the organize.document-properties parity entry: both evidence lists kept, main's XMP note
kept, and the tab list now names main's Custom tab (#4387) instead of saying there is none.
* fix(ui): text typed into a new text box counts as unsaved work
Closing a tab or quitting while the only unsaved thing was text in a new Add text box skipped the save prompt and lost the text. The box now counts like fill text does: only when it holds non-blank text.
* fix(ui): saving on close keeps text typed into a new text box
#4430's merge with main left mod type3_text_clip before mod tiling_cell_placement,
so main's fmt check failed.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The page pointer has the pointer only where egui has the pages on top, and egui knows what
is drawn over them from the layers it met in the frame before. Dialogs are drawn after the
pages, so on the frame one opens egui has not met its layer yet. For that frame the pages
kept the pointer, and a press that landed then was theirs: a link's tip showed over the
dialog, and a click placed a stamp or followed a link under it. main's page_covered had
kept the link's tip and click off the page while a dialog was open, from a hand-kept list.
PagePointer::read now also takes whether a dialog is open: PdfCraftApp::modal_open, the
list the page press and the edge scroll already check, the Unlock prompt included. With one
open the pages see no pointer and no press, except a drag they already have, which goes on
to its release. The palette is not a dialog and the page beside it keeps its tools, so it
is left to egui's layers, from the frame after it opens.
Tests: tests/it/pointer.rs hovers a link and opens the Unlock prompt (no tip in that
frame), and clicks with the Stamp tool in the frame the prompt opens (no stamp). A
page_pointer unit test hides the pointer and a click under a dialog, and keeps a drag the
pages have until its release. With the fix reverted, all three fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click on a check box or radio button focuses it, so Tab, Space and the arrows reach it
(#2223). The page hands the click to the field, and the field overlay toggles the box in
the same frame unless the click landed elsewhere. That check read hover_pos, which is None
on the frame egui reports a tap: egui-winit sends PointerGone with every touch release,
and winit reports Windows pen input as touch. So a finger or pen tap counted as a click
away. The focus closed and the toggle was dropped: a tap left a check box unticked and
unfocused, and a tap on a radio button selected nothing. The check now uses the overlay's
clicked_outside, which reads interact_pos, as the other field editors do.
Tests: touch_tap_ticks_a_check_box runs again. tests/it/editor_taps.rs taps a focused
check box and the other button of a focused radio group, each with a mouse click as the
control. Each checks that the focus stays and the tap toggles, and for the radio group that
the arrows still move within it. With the fix reverted, the three tap tests fail and the
controls pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The form field editors and the Edit text paragraph editor are drawn over the pages,
outside the page pointer, and close on a click away. With a finger, or a pen on
Windows (winit reports it as touch), they closed on taps inside them:
- A tap on a combo box opened its list and closed it in the same frame, so a choice
could not be picked by touch.
- A tap on a list box row picked it, but the list lost focus, so the arrow keys no
longer moved the choice.
- A tap on a date field's calendar arrows closed the field.
- With a paragraph open in Edit text, a tap on a Format text control that only
opens a list, such as Line spacing, applied and closed the paragraph, and the list
went with it.
Two things were wrong. The combo and list boxes judged the click by hover_pos, and
the paragraph's "outside the document" by latest_pos. Both are None on the frame egui
reports a tap, since egui-winit sends PointerGone with every touch release. They now
read interact_pos, which keeps the tap's position on that frame (egui's own
clicked_elsewhere does the same). And egui 0.36 reports a lost focus for two frames.
The text field editor and the paragraph editor judged their exceptions again on the
second frame, when a tap has no position left and a mouse may have moved on. They now
judge a loss once, on its own frame (edit_text_ui::lost_focus_once, with a flag kept
per editor). A focus taken later in a frame is still seen on the next one, as before.
Tests: tests/it/editor_taps.rs taps with support::input::touch_tap, which sends
the release and PointerGone in one frame as egui-winit does, and pairs each tap with a
mouse click as the control. That click leaves the pointer in the window: kittest's
drop_at sends PointerGone a frame later, which the second-frame judging also turned
into a click away. With the source reverted, the four tap tests fail at their bug
assertion and the controls pass. Reverting one part at a time fails only its own
tests: hover_pos in the combo and list check, egui's lost_focus in the field editor
(the calendar) or in the paragraph editor, and latest_pos for the paragraph's outside.
A tap inside an open text field already worked and has a guard test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-line non-negotiable in CLAUDE.md, next to Layering, so agents and contributors
know the rule before the test tells them: page modules read the pointer through
PagePointer (crates/ui-egui/src/page_pointer.rs), never hover_pos, interact_pos,
latest_pos, press_origin or the button reads. It names the test that enforces it,
page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain #[test] in page_pointer.rs reads the source of the page modules (the per-page
chain in canvas::document_area and the modules it calls) and fails on any raw read of
egui's pointer state: hover_pos, interact_pos, latest_pos and press_origin, Context's
pointer_*_pos shortcuts, and the primary_* and any_* button reads. Its message names the
file and line and points to PagePointer. 24 open PRs add such reads to these modules, and
three of them (#2137, #1709, #2235) merge cleanly on top of the snapshot, so they would
quietly bring back the touch and overlay bugs it fixed. CI's existing cargo test
--workspace runs it on Linux and Windows.
To pass, the snapshot also carries the button state (primary_down and any_down, global as
before), and the page-input button reads that were left use it or the frame's press edge:
drag ends in Prepare, redact, the Link tool, added content, Edit text and objects, the
comment grab check, a text field opening on the press, and the press that makes a page
current. Code in these files that is not page input is listed, each with its reason, in
NOT_PAGE_INPUT: the field, paragraph and added-text editors (Areas of their own), the
signature dialog's Scan tab, Organize Pages, the page size hint, comment key handling, and
four view gates in document_area that run before the pages' widget exists (zoom anchor,
drawing edge scroll, glide interruption, full screen's click to turn the page), now small
named functions. Prepare a form's calculation order overlay (calc_order_ui) is a page module
too, and takes the pages' pointer for its hover. The test also fails
on a list entry that reads no pointer state any more, and on a source file that takes
PagePointer without being listed as a page module.
Seen failing: a raw hover_pos() put back into zoom_snap.rs, and main's raw read put back
into the stamp block of document_area, are reported with their lines, as are a stale list
entry and crop.rs left off the module list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page view had no single reading of the pointer. About twenty page modules each
read egui's raw state (hover_pos, latest_pos, interact_pos, press_origin) and tested it
against an unclipped page rectangle. That state knows nothing of what is drawn over the
pages, and hover_pos is None on the frame a finger or a Windows pen lifts: egui-winit
sends PointerGone with every touch release, and winit reports Windows pen input as
touch. So a tap placed no stamp, followed no link and ticked no check box. A touch drag
of a comment never committed, and the next drag anywhere on the page moved the comment.
The Stamp crosshair showed on the command palette, on the quick bar and on the chrome
over a page scrolled under it, a link's tip floated over the Unlock prompt, and a
double-click on a dialog's backdrop selected the word under it.
canvas::document_area now reads one PagePointer per frame from the pages' content
widget (step 1 of the page pointer router in the cursor-handling review):
- pos: interact_pos, which keeps the position through a touch release, only while egui
has the pages under the pointer with nothing over them (contains_pointer, and the top
layer there is the pages', as the closed#2626 did) or while the pages hold a press.
- origin: where the held press began, also on the frame that brings both the press and
its release. This replaces the text layer's raw-event fallback (#2100).
- edges: the frame's primary presses and releases on the pages, in order.
It reaches the page modules through comments::PageCx and as an argument: the stamp
blocks, text layer, page press and link and comment hover in canvas.rs; comments and the
signature drag preview; forms and Prepare a form; Fill & Sign and the Sign area; crop,
marquee zoom, snapshot, camera and measure; redact and the Link tool; added content,
Edit text, images and objects. page_covered, a hand-kept list of what covers the pages
that missed the Unlock prompt, is gone. The pages' hover tip is click-through, as a
tooltip is, so a quick move onto it does not take the pointer off the link under it.
Tests: tests/it/support/input.rs sends pointer input in the frames the platform delivers
it in (a touch tap and drag, a click in one frame, a double-click). kittest otherwise
runs one frame per event, so the suite never saw a touch release. tests/it/pointer.rs
holds the review's probes and #2626's layer tests. With the fix reverted, the
tap (stamp, link, check box), touch drag, palette, quick bar, chrome, Unlock tip and
dialog double-click tests fail, and the mouse-click and one-frame-click controls pass.
The tip test fails with the tip interactable, and each page_pointer unit test fails
under a mutation of the part it pins. The probes for later steps stay ignored: the
link hand and the I-beam over other tools' targets (step 2), paragraph edges (step 3)
and Escape mid-drag (step 4). touch_tap_ticks_a_check_box is ignored until the
check box focus fix that follows: since #2223 a tap on a check box counts as a click away
from its focus.
tests/it/comments.rs: the multiline markup hover test (#3541) hovered a point below the
window on a page rotated 270 degrees, and its tip showed only because the hover was
tested against the page's whole rectangle. Its harness now zooms to 100 %, so every
rotation shows the whole page, and the hover helper asserts the point is on screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A combo box with the Edit flag (bit 19) opened only its list, so a value
that isn't one of its options could not be typed on the page, although
form_fill already took one. ISO 32000-2 12.7.5.4 and Table 233: with Edit
set, "the combo box shall include an editable text box as well as a
drop-down list".
- An editable combo box now opens a single-line text box over the field
as well as its list. Enter, Tab or a click away commits the text as
Edit::SetFieldValue with a Choice, the path form_fill and the list use,
so undo, field scripts and the appearance follow; a pick from the list
still sets an option.
- The editor starts on the shown text of the picked option, and typing a
shown text picks that option (the engine maps it to its export value).
- A combo box without Edit is unchanged: a drop-down list only.
- Tests: kittest typing into an editable combo (Enter, list pick, Tab,
save, undo) and a non-editable one refusing typing; automation
form_fill takes free text only with Edit, drawn before and after save.
- Parity: form.fill-choice notes and evidence.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M14 #4253: remember the annotation tool pin across restarts
* M14 #4253: clarify roadmap timezone and Markdown formatting
* Keep main's roadmap status lines; only the progress-log entry is new
* Take main's forms.rs: main already gives each form test its own folder
* Roadmap: drop the form-test folder note (main already has its own)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The special colourant name All refers to every colourant of the output
device at once, and on an additive device its subtractive tint is
complemented before it is applied (ISO 32000-2 §8.6.6.4). The vendored
colour code treated All as an ordinary spot colourant and drew it through
its tint transform, so a /DeviceCMYK transform giving cyan drew 0.5 scn
as light cyan instead of a 50% gray.
Vendored hayro-interpret patch 66: Separation::convert_f32 writes the gray
1 − t for each tint of an All space without evaluating the tint transform.
Fills, strokes, images and shadings in that space all convert through it,
and so does the RGB print raster. /None still paints nothing, other names
still use their alternate, and a DeviceN space naming All keeps its
alternate (the clause defines the name for Separation spaces).
Tests: crates/render/tests/it/separation_all.rs (synthetic).
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
ISO 32000-2 9.3.6: for a Type 3 font, modes 3 and 7 shall not be
rendered, the other modes render the glyph descriptions, and modes
4 to 7 add nothing to the clipping path. Vendored hayro-interpret
patch 42 drew every mode but 3 filled, so 7 Tr painted the glyphs
in the fill colour (black by default) wherever a following fill
did not cover them.
Patch 64: show_glyph keeps mode 7 for Type 3 glyphs and reports
them as clipping text (GlyphDrawMode::Clip, painting nothing, kept
by extraction), and clip_glyph's Type 3 arm is a deliberate no-op.
Modes 0-2 and 4-6 still render filled.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* refactor(xfa): replace model wildcard imports with explicit names (Sonar S2208)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(xfa): complete explicit model imports
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(xfa): drop unused model imports
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Import Corner, which main's parse.rs now uses
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(ui): header, footer and watermark previews step through the pages
The marks dialogs preview the current page, or the first page the range
selects when it leaves the current one out, with no way to look at
another page. Arrows beside the page label step the preview to the
previous or next page the range selects. The page stepped to resets
each time the dialog opens.
* fix(ui): the marks preview forgets a stepped-to page the range leaves out
The Bookmarks panel opened every top-level bookmark whatever its /Count
said, so a bookmark with a negative /Count showed its children. Every
level now starts as its /Count says (positive open; negative or missing
closed). The reader's toggles and a search still override it.
Moving a bookmark into a different parent (Indent) now opens that
parent, as adding one already did; the forced top-level open used to
hide that it stayed closed.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M4: Document Properties has a Custom tab for custom properties
Document Properties has a new Custom tab between Fonts and Advanced. It
lists the document's custom properties, and Name and Value fields with
Add, Update and Delete edit the list. Clicking a listed property fills
the fields with it. OK writes the changes through SetInfo in the
existing "Change document properties" step, so one undo reverts them
with the other tabs' changes.
Add is off for an empty value, a name already listed, a name that
check_custom_key refuses (its message is the button's tooltip), and a
name the file already uses for an entry the list leaves out (not text,
only padding, or past the 256 listed). A property already in the file
under a refused name or a name that is not UTF-8 is listed, and Add,
Update and Delete stay off for it, so OK cannot fail on it or write a
different key.
Clearing an /Info entry now removes it when /Info is a dictionary
written directly in the trailer. set_info_entry returned without a
change there, so a delete in the Custom tab, a cleared Description field
and a doc_set_info clear left the entry in the file. It now writes a
copy without the entry as a new indirect /Info.
The engine re-exports check_custom_key and adds Document::has_info
(organize::has_info). set_option("dialog", "custom") opens the tab.
* Contributors: add dotCooCoo (Robert Lee)
* M4: Add in the Custom tab stops at the 256 properties the tab lists
Document Properties > Custom lists at most 256 properties, so a property
added past that was saved but left out of the list when the dialog opened
again. Add is now off once the tab's list or the file's own list holds 256;
a new name goes after the file's entries, so a file with more than 256 keeps
Add off after a Delete as well.
- render: DocInfo::open_dest resolves /OpenAction (an explicit destination,
a /GoTo action's /D, or a name or string through /Names /Dests or the
catalog /Dests) with the existing bounded dest() resolver.
- ui-egui: apply_initial_view goes there with DocView::go_to_dest, so the
/XYZ point lands at the window's top-left and /FitH top at its top edge.
With "Always use my default zoom and page layout", only the page.
- organize, ui-egui: /ViewerPreferences /NonFullScreenPageMode (Table 147)
is read for /PageMode /FullScreen documents and names the panel shown
behind the full-screen prompt; such a document no longer gets the outline
panel only because it has an outline.
- organize: set_initial_view keeps an opening action whose page and
magnification are unchanged, instead of dropping its position or deleting
a named go-to.
- automation: doc_info reports open_destination.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A Line annotation without an appearance was drawn along /L with its
endings there, ignoring /LL, /LLE, /LLO, /Cap, /CP and /CO. With /LL,
/L gives the ends of the leader lines, not of the line itself.
- The line sits |LL| + LLO from /L, on the left of travel for a
positive /LL; leader lines run from LLO off /L to LLE past the line.
- /LE endings are drawn at the line itself.
- /Cap true draws /Contents (or the plain text of /RC) in black
Helvetica 10, turned with the line: Inline breaks the stroke around
it, Top sets it above, /CO shifts it (with a tick when moved off).
- PdfCraft measurements keep /PCMeasureValue as their caption.
- undrawable_text reports caption text the standard font can't draw.
- Non-finite, huge and negative values, and a zero-length /L, are
handled leniently; captions are cut to 256 characters.
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M12: XFA check boxes sit where their margins, caption and alignment put them
* M12: a check box's automatic caption room and auto height follow the box as drawn
* M12: a caption reserve of zero sizes the caption to its content, as no reserve does
With only --automation-read-root / --automation-write-root set, a path
went straight to std::fs::canonicalize, so a UNC, WebDAV or device path
(`\host\share\x.pdf`, `\.\pipe\x`) was resolved, contacting the host,
before it was refused. `--root` mode already refuses those by name
(`foreign_share`).
`foreign_share` moves into file_access, and `AutomationRoots::check_path`
applies it to the path made absolute (by name, nothing read) before
following links, with the same refusal as any other path outside the
root. A root on a share still accepts that share. `resolve` is split
into `absolute` (empty path, device names, made absolute) and `resolve`
(links followed), so `out\NUL` keeps its own refusal.
Part of #4003 (the first of its three points).
The cell pixmap was round(scale x step) pixels and repeated, but the cell
was drawn, and the paint transform mapped back, at the unrounded scale.
So the pixmap repeated a fraction of a pixel too far at every step (31 px
for a 30.56 px step at 110 dpi), and cells drifted further from their
place with every step from the pattern's origin.
Vendored hayro patch (25), `tiling_cell_pixels`: the cell is drawn at
round(scale x step) / step, so one step is exactly the pixmap's side, and
the paint transform uses that same scale. The side stays within 1 and the
existing 3000 px cap; a non-finite product keeps the scale and a bounded
side.
Tests: crates/render/tests/it/tiling_cell_placement.rs (fill, turned and
scaled matrix, stroke, all at 110 dpi far from the origin; the helper's
bounds).
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With the view turned 90 or 270 degrees, the page's screen rectangle has
its sides swapped, so xf.rect.width() / xf.pw was off by the page's
height over width: Fill & Sign, form fields and Add text drew typed text
at the wrong size, and Measure snapping, Prepare form, the calculation
order and field table overlays were sized wrong too. They now use
PageXform::zoom (from #3749), which reads the side the width is on.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Clean Up removes named destinations that nothing in the document refers
to. referenced_names counted the /D of GoTo, GoToR and GoToE actions
alike. The /D of a GoToR or GoToE action names a destination in another
document, so a local destination with the same name was kept although
nothing in the document used it.
Only a GoTo's /D now counts as a use. A /Dest key and a GoTo's /D are
read as before.
Test: links with a GoTo, a GoToR and a GoToE action name three
destinations; optimize() keeps the one the GoTo uses and removes the
other two.
content_draws tells whether some content draws an XObject, directly or
through a form that has no /Resources of its own and so draws through
its caller's. Since #4356 the recursion passed the caller's content
again instead of the nested form's decoded data, so a name drawn only
inside such a form was never found.
Both callers then dropped the entry: unlink_doomed for page-tree
resources (Apply Redactions and the content pass of Remove Hidden
Information) and prune_doomed_xobjects for surviving forms. An image
removed under a mark but still shown elsewhere through such a form went
missing from there as well.
The recursion reads the nested form's data again, as it did before
#4356. Two tests cover the page-tree case and the surviving-form case.
Both fail without the fix.