Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Resolves test-file conflicts where main and this branch both appended tests:
main's file plus this branch's appended tests, unchanged.
fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* M5: Import comments takes another PDF and copies its comments onto the same pages
Import comments read only XFDF, FDF, XML, XDP, CSV and text data files.
It now also takes a PDF: each comment of that PDF is copied onto the
page with the same number, at the same place, with its appearance
stream, its pop-up, its replies and its layer. Comments on pages past
the document's last page are left out, together with replies to them.
Links, form fields and redaction marks are not copied. A comment named
like one already on its page replaces it, as an XFDF or FDF import
does. A PDF without comments is refused with "nothing in the file
matches this document", and a PDF that needs a password is refused.
- organize: import_comments copies the comments with the page copier
that Insert Pages uses, and registers the layers they use.
- engine: Edit::ImportData reads a file that starts with %PDF- as a PDF.
Any other file goes to the data importer first, and is read as a PDF
when that fails, %PDF- occurs in its first 1,024 bytes and the PDF
reader opens it.
- ui: Comments > Import comments lists .pdf files; form.import_data
lists the same files as before.
- automation: the doc_import_data description names the PDF case.
- parity: comment.import-from-pdf is shipped.
* Import comments: an annotation that is no comment keeps its name
A comment imported from a PDF replaced any annotation of the same /NM
on its page that was not locked, a redaction mark or a widget, so a
link, screen or 3D annotation of that name was overwritten. Only an
annotation whose subtype is one of the copied comment subtypes is
replaced now; for any other, the copy is added beside it without the
name. The subtype is read through an indirect reference.
* Import comments: index each page's annotations instead of scanning them per comment
* Import comments: a layer that BaseState OFF hides stays hidden
* Import comments: a PDF with binary bytes before its header imports on an XFA form, and two comments with one name both land
On an XFA form, a decode failure no longer stops the import when a PDF
header is in the first 1,024 bytes; the PDF's comments are copied. Two
incoming comments on one page with the same /NM no longer replace the
same comment: the first replaces it, and the second is added without
the name.
* Import comments: an inline annotation on the page is replaced by name
An annotation written inline in a page's /Annots is made an indirect
object before names are matched, so an incoming comment with its /NM
replaces it instead of being added beside it.
* Import comments: two new comments with one name keep it once
When no comment on the page has the name, the first incoming comment
keeps its /NM and each later one with the same name is added without it.
* M6: Organize pages ▸ Page properties sets the selected pages' tab order
Page properties in the Organize pages panel was listed as planned. In
the app, a page's tab order could only be set for every page at once,
with the form.tab_order commands, which offer no Unspecified. (The
form_tab_order tool already set chosen pages, Unspecified included.)
- Page properties (page.properties) opens a Tab Order dialog for the
selected pages, with Use Row Order, Use Column Order, Use Document
Structure and Unspecified. OK writes Edit::SetTabOrder for those pages
as one undo step. The dialog opens on the pages' order, or with no
choice made when they differ, and then OK is off until one is chosen.
OK on the order the pages already have adds no undo step.
- Rows and columns run right to left in a document bound on the right,
as Use Row Order and Use Column Order are described. PdfCraft reads a
right-to-left binding from /ViewerPreferences /Direction /R2L
(ISO 32000-1, 12.2, Table 150). Before, rank_tabs ranked them left to
right.
- A page whose /Tabs is an indirect object now takes its row, column or
structure order. Before, rank_tabs read only a direct name and ranked
the page in annotation order.
- pdfcraft_forms::tab_orders and Document::tab_orders read each page's
/Tabs. form_tab_order returns them as page_tabs, and reads without
changing anything when it is given no order or field.
- set_option("dialog", "page-properties") opens the dialog.
- Parity: organize.page-tab-order is shipped, and its notes say the
Actions tab of Page Properties (organize.page-actions) is still
planned; the Organize pages grid notes list Page properties.
* Page properties: OK applies only to the document the dialog was opened on
The draft keeps the document's id. When another document is active by
OK, the dialog closes without changing either document.
* Menus: list Page properties… in the Pages menu
The menu bar now lists its commands explicitly, and every command with a menu group must appear in it.
* Page properties: apply the tab order only to an unchanged document
An edit, undo or redo made while the dialog is open can move the pages
at the stored indices. OK now closes the dialog without changing a page
when the document's edit generation differs from when it opened.
* Page properties: say when a document change cancels the tab order
OK shows a notice when the document changed or another document became
active while the dialog was open, so the user knows to open it again.
* M6: form script messages keep their title, icon and buttons, and every one is shown (#4293, #4294)
* M6: a form script message waits for a notice the toast already shows
* M6: form script messages past the queue's room are counted and reported
* M6: a sentence break in the merged parity note
* M6: an object that names no parameter is a message's text, as a String object or a caught error
* M6: the count of left-out script messages is said again if another notice replaces it before its time
* M6: a full stop between two sentences of the FormCalc parity note
* chore: re-run CI (a frame-budget timing test missed on a shared runner)
The menus greyed Delete out from the listing's reply links while the
engine refused on the PDF's own /IRT and /Parent links, so the two could
disagree. pdfcraft_annot::deletable is now the one check: delete_annotation
runs it, and Document::annotation_deletable gives the Comments panel and
the page's context menu the same answer before they offer Delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The locked-reply check in delete_annotation also reached stamp_preview
and signature_image, which only take the annotation off a copy to draw
the page beneath it: an unlocked stamp with a locked reply lost its drag
preview. They now use lift_annotation, which checks no locks.
The Comments panel's Delete (comment and reply menus) and the page's
context menu are greyed out while a reply below is locked
(comments::locked_below), rather than refusing after the click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting a comment or a reply took every reply to it, locked or not: only
the target's own Locked flag was checked. A locked reply can't be deleted
on its own, so delete_annotation now refuses while anything that would go
with the target is locked (pop-ups follow their comment).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main split canvas.rs, comments.rs, edit_text_ui.rs and moved the page
modules under canvas/ and tools/. The cursor claims are re-applied where
that code now lives:
- page_cursor.rs moves to canvas/, beside page_pointer.rs, re-exported
from lib.rs the same way.
- canvas.rs: claims in the stamp, hand, text-cursor and link code;
over_field and badge_hover are gone; the cursor is resolved once after
the page loop in draw_pages. annotation_hover_text (canvas/area.rs) and
its test give way to comments::describe and hover_tip.
- comments: describe, hover_tip and on_link go to tools/comments/input.rs,
the hover_tip test to tools/comments/tests.rs.
- Edit text's picture claims go to tools/edit_text_ui/image.rs.
- The cursor guard test goes to canvas/page_pointer/tests.rs, with
NOT_PAGE_CURSOR on main's paths.
- Tools that took ui only to set the cursor stop binding it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main split comments.rs into tools/comments/: annotation_deleted,
after_delete and on_page go to state.rs, their test to tests.rs. main
pulled the post-edit selection into select_after_edit, which now takes the
deleted page's annotations from before the delete. ROADMAP.md and
docs/roadmap.md keep both entries, main's newer one first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main split lib.rs: the unmarked_page field stays on PdfCraftApp in lib.rs,
its init and the render_pending check moved to app.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main split and refactored the text-rewrite code this PR changes
(automation dispatch.rs, edit added/ and text/, text_regions/styled.rs).
Conflicts in added/store.rs, text/rewrite.rs and text_regions/styled.rs
take main's structure, with per-row alignment applied in it:
- rewrite.rs: LineWriter keeps the paragraph's alignment and the rows'
own (align, aligns); each line looks up its row's alignment
(align_of) for justify and for its offset. Alignment::offset takes the
row's alignment. Wrapped lines carry their row, as wrap returns it.
- styled.rs: wrap_styled_rows also returns each row's draft line
(row_lines), which the kept-rows check counts aligns by.
- Imports for the moved modules (alignment_frame, BlockStyle,
align_code, align_from, content in dispatch.rs).
Adopt upstream's split set_props: rename_field returns the group a renamed
radio button joins, and redraw_or_join keeps set_props under the new
cognitive_complexity limit. join.rs imports explicitly (wildcard_imports).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Adopt upstream's split set_props and explicit import lists. The list box
scroll after a property edit moves into scroll_list, keeping set_props under
the new cognitive_complexity limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
# Conflicts:
# crates/automation/src/lib.rs
# crates/engine/src/lib.rs
# crates/ui-egui/src/lib.rs
# crates/ui-egui/src/panels.rs
Main's module layout (#4889) moved what this branch changed: the tools to
automation's dispatch.rs (the levels parser to json.rs), the edit to
engine's edit.rs, edit/labels.rs and edit/run.rs, Document::text_styles
to document.rs, the dialog state to state.rs and app.rs, and the menu
item to panels/right.rs and panels/bookmarks.rs. The dialog module joins
the others in dialogs/. Main's craft-menu bar (#4752) lists its commands
by hand, so bookmark.from_text_styles is added after
bookmark.from_structure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR
set_props chose /TI inside the field-dictionary update, from the field
read before the edit, so a font size or look changed in the same call
was not counted: at a larger size fewer rows fit and the selection
could end up below the visible rows. The field and widget top rows are
now chosen after every layout change, from the field read again,
before the redraw.
Test: a_list_boxs_top_row_follows_a_font_size_changed_with_its_items
(fails with the old ordering: rows 1..2 shown, selection on row 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A widget's own /TI is read before the field's, so each one scrolls by its
own top row and size (ListRows::top_showing). Before, the widgets were only
moved, all to the first widget's new row, when the first widget itself
scrolled: a second widget scrolled past the remaining options, or away from
a new value the first already showed, kept its stale row.
Test each_widget_with_its_own_top_row_scrolls_by_itself covers both
set_props (options shrink) and set_value; restoring the first-widget gate
in either caller makes it fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/V is inheritable like /DV (ISO 32000-2 §12.7.4.1). Clearing a text or
choice field's value removed its own /V, so a parent field's /V showed
through: Reset Form on a kid with no default refilled it with the parent's
value. When an ancestor sets /V, an empty value is now written instead of
removing the key. Check boxes and radio buttons already write /Off.
Test clearing_a_kids_value_does_not_bring_back_its_parents covers a text
kid and a list box kid through reset and through set_value; each half of
the fix was switched off in turn and the test failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
/DV is inheritable (ISO 32000-2 §12.7.4.1). set_props removed a cleared
/DV, which brought back a parent field's, so Reset Form restored the
parent's default. When an ancestor sets /DV, a cleared default is now
written empty (/Off for buttons, () otherwise); with none it is removed
as before. New tree::ancestor_sets, cycle-safe and capped.
Adapted from pdfcer (MIT, same author) 6af2773ccf599b416fab379673119c6b37c55a2c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Changing a list box's options could leave /TI (the first visible row)
pointing at a row that moved or no longer exists. A widget's own /TI is
read before the field's, and one past the end draws no options at all.
set_props now runs the same ListRows::top_showing rule set_value uses
(ISO 32000-2 12.7.5.4): /TI stays put while the selected rows are still
visible, otherwise it scrolls to them, clamped to the last full page.
The field's /TI is updated, and so is every widget that carries its own.
The scroll helpers move out of set_value so both paths share them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
set_props rewrote /Opt (an items edit or "Sort items") but left /I, the
selected positions in /Opt (ISO 32000-2 §12.7.5.4), so /I named whatever
now sat at the old positions. It now re-points /I at the selected values'
new positions, by set_value's rule, and removes it when none is offered;
a field /TI past the end of the new list is removed.
Adapted from pdfcer (MIT, same author) 149db0662ecfe5b23a050cdb7513821da918d7cb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Renaming a radio button to an existing radio group's name was refused as
a duplicate, so buttons placed separately could never become one group.
set_props now moves the renamed field's buttons into the named group
(ISO 32000-2 §12.7.5.2.4: a group is one field whose widgets are its
buttons). A clashing export value is renamed to the first free ChoiceN
in /AP /N, /D and /R; a moved button is on only if its value is the
group's value; merged field+widgets are handled on both sides (the
target split via split_merged, now shared with duplicate_field).
Refused when either group has /Opt, has no button, or the target is
locked.
Adapted from pdfcer (MIT, same author) 79870b56 and 116f48d3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
main deleted crates/engine/src/reexports.rs and moved its re-exports
into crates/engine/src/lib.rs; the comb_auto_text_size re-export this
branch added now sits in lib.rs. forms_ui.rs moved to
crates/ui-egui/src/tools/; the branch's hunks carried over unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
In Edit text & images a click inside a paragraph opens it for typing, and selecting the whole
box meant aiming for its thin frame: a press a few pixels inside put the caret in the text
instead (#2203). Esc in the inline editor now leaves the text and selects the paragraph's box,
as Acrobat does, so Esc then Delete removes it, the arrow keys nudge it and its frame drags it.
Esc still discards an unapplied draft.
The editor asks `ObjectSelection` to select the paragraph, and the next `prepare`, which has the
document's inventory, selects it after its own Escape check, so the Esc that closed the editor
cannot clear the new selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An auto-sized (`0 Tf`) single-line text field drew at most 12 pt however
tall the box. Acrobat Reader draws it at the size the height gives (checked
black-box on a synthetic form: 30 pt and 60 pt fields); ISO 32000-2
§12.7.4.3 leaves the size implementation-defined. auto_text_size_by now
keeps the existing height / 1.15 size without the 12 pt cap, still shrinks
to the width and never goes under 4 pt, with the height bounded at 14,400
(MAX_AUTO_HEIGHT, the largest page side ISO 32000-1 Annex C recommends).
Multiline and comb auto-size keep their 12 pt cap: not yet checked against
Acrobat.
Tests: the forms unit tests now pin a tall box's size (they pinned 12 pt),
and an automation test fills a 30 pt field through form_fill and measures
the drawn text with text_find. All three fail with the cap restored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9