* M4: Document Properties reads a named opening destination's page, so choosing page 1 replaces it (#4572)
initial_view resolved only explicit opening destinations, so a named one
(ISO 32000-2 §12.3.2.4) read as page 1 at the default magnification, and
set_initial_view kept it as "unchanged" when page 1 was chosen. Names are
now resolved through the /Names /Dests tree and the catalog /Dests with the
existing bounded resolver, and a bare name or string counts as a destination
when page 1 at the default magnification removes /OpenAction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
* M4: parity notes say page 1 at the default magnification removes /OpenAction
Document Properties replaces a named opening destination (ISO 32000-2
§12.3.2.4) with an explicit one when another page or magnification is
chosen; page 1 at the default magnification removes the catalog's
/OpenAction instead (§7.7.2), so the first page opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
* chore: resolve SonarCloud maintainability issues
* fix(packaging): use verify_sha256 helper function
* style: cargo fmt
* fix: restore wildcard imports and fix method-reference type errors
Revert the glob-import expansions (they missed SomIndices and left unused
imports), revert char::is_ascii_digit where the iterator yields char by
value, and pass the content slice to prune_doomed_xobjects as &[Vec<u8>].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep XObject decodability check without binding content in interp
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
* M4: the Bookmarks panel shows each bookmark's colour and style (#3193)
ISO 32000-2 §12.3.3: an outline item's /C is "An array of three numbers
in the range 0.0 to 1.0, representing the components in the DeviceRGB
colour space of the colour that shall be used for the outline entry's
text" (Table 151), and its /F flags say italic (bit 1) and bold (bit 2)
(Table 152). Both were ignored: every title was drawn in the theme's
text colour and face.
The inspector (OutlineItem) and the bookmark list (organize::Bookmark,
used once a document is edited) now read them: each component is kept
within 0-1, and black (the default) or a /C that isn't three numbers
reads as no colour. The panel draws bold titles in the semibold face,
italic ones slanted, and the colour as given, lightened towards the text
colour only as far as it needs to read on the panel (3:1), so dark blue
stays visible in the dark theme. bookmark_list reports color, bold and
italic where they differ from the defaults.
Mutants, each failing a test: italic or bold bit ignored (both readers,
the panel), black kept as a colour, no clamping, a four-number /C
accepted, colour ignored or truncated in the panel, the contrast floor
removed, always jumping to the text colour. Checks for non-finite
numbers were equivalent (clamping already covers any size), so they
are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M4: set a bookmark's style and colour from its menu or bookmark_set_style (#3193)
#3193 asks for a way to mark bookmarks by colour, for example chapters
that are rarely used. ISO 32000-2 §12.3.3 gives every outline item a
title colour (/C, Table 151, DeviceRGB, default black) and style flags
(/F, Table 152: bit 1 italic, bit 2 bold, default 0); the previous
commit shows them, and this one lets the user change them.
- organize: set_bookmark_color writes /C, removing it for black (the
default) and refusing a component outside 0-1; set_bookmark_style
sets or clears the bold and italic bits, keeps any bit the table
doesn't define, and removes /F when no bit is left.
- engine: SetBookmarkColor and SetBookmarkStyle edits, undoable, under
the same assemble permission as the other bookmark edits. Their undo
labels are the menus' own names, already translated in every
catalogue.
- ui-egui: the bookmark menu gains Style (Bold and Italic check boxes)
and Colour (the comment swatch grid, with Other colour...). Only
existing UI strings are used.
- automation: bookmark_set_style takes any of color ([r, g, b], 0-1),
bold and italic, and changes only what it is given.
Mutants, each failing a test: no range check (library and tool), black
kept as /C, a bit not set, a bit not cleared, an untouched bit cleared,
/F 0 written, the bits swapped (library and menu), the tool accepting
nothing to change or ignoring bold/italic, the menu's colour ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M4: a colour and a style set in one bookmark_set_style call are one undo step (#3193)
Review on #4171 (CodeRabbit). Given both, the tool applied two edits,
so undo took back only the style and left the colour, and a failing
second edit left the first applied. They now go in one Edit::Batch
named "Style", all or nothing. "Colour" and "Style" join EDIT_LABELS,
and "Style" BATCH_LABELS, so the history can translate them.
The test sets colour and style together and undoes once; with the two
edits applied separately it fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
crates/organize/src/import.rs: upstream's name_tree_entries (seen-node set, each node read once) kept whole; this PR's only change to it, pub(crate) for attachments.rs, applied on top.
crates/organize/src/attachments.rs: its name_tree_entries call passes the new seen set.
crates/automation/tests/attachment_key.rs, crates/ui-egui/tests/attach_picker_target.rs: moved into tests/it/ and registered in main.rs, following upstream's one-binary layout (organize keeps its single test file, as upstream's rule says).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`get_property` returned the raw child markup when a property had no
`rdf:li` items, so `<dc:title><rdf:Alt/></dc:title>` with no /Info title
showed "<rdf:Alt/>" in Document Properties. Character data that still
holds a `<` is markup, never a value: an empty Alt/Seq/Bag, or an item
holding a nested structure, now reads as none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Description read Title, Author, Subject and Keywords only from /Info. A file
that states them only in its XMP packet (/Info is deprecated in PDF 2.0, and
some writers leave it out) showed empty fields, while Acrobat shows them.
organize::info now falls back to the packet's dc:title, dc:creator,
dc:description, pdf:Keywords, xmp:CreatorTool and pdf:Producer: a language
alternative gives its x-default item, and several authors are joined with
"; ". The engine fills the inspection's missing fields the same way, so the
read-only Description rows and doc_info agree with the editable fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests each branch appends to the audit, edit and organize test files are
kept side by side; prune.rs merges cleanly, with the form memo on top of the
budgeted page content.
Each candidate attribute match searched the whole packet before it for the
last < and >, so a long text node repeating the attribute name cost time
quadratic in the packet size (up to the 16 MiB decode cap). The scan now
keeps the last < and > as it moves forward.
The pruner decoded and walked a form XObject again on every Do that drew
it, so a form drawing itself a few times per level was analysed
exponentially often within the depth limit. Forms are now remembered with
the resources owner they were drawn against, and a repeat records nothing
new.
Looking up a named destination and listing attachments in a source's name
trees followed every /Kids entry with only a depth limit, so a node listing
the same child twice per level made the walk (and the attachment list)
exponential in the depth. Both walks now skip a node already read.
Fixing up copied annotations cloned the whole source-to-destination map for
every non-GoTo action and dropped the copies it made, so many annotations
cost quadratic memory and time and an object shared by their actions was
copied once per action. The copier now lends its map to the action copy and
takes it back with the new entries.
Renaming a colliding field tried Name-1, Name-2, ... from 1 every time, so
many fields of one name cost a quadratic number of attempts during import,
combine and their conflict previews. Taken names only grow, so the search
now resumes after the last suffix handed out for that name; the result is
still the first free variant.
Each imported top-level field was checked against every field already in
/AcroForm /Fields before it was appended, so a page with many distinct
widget fields cost time quadratic in their number. Membership is now kept
in a set beside the list.
Adds a test-only work counter to the crate, so tests can bound the work a
hostile document causes without timing it.
Pruning and page_as_form joined every /Contents entry with a fresh
1 GiB allowance each, so a page naming one compressed stream many
times became gigabytes. Both share one helper that decodes against a
per-page DecodeBudget and fails when it runs out.
page_extract (and Split, which extracts each part) wrote the copied
pages without any outline, even when every page was extracted, though
the tool says bookmarks come along.
extract_pages now reads the source's bookmarks with the destinations
mapped to the copies, as Combine already does, and writes them as the
new document's outline. A bookmark whose page was left out goes, unless
a bookmark under it stays. A source without bookmarks still gets no
outline.
Fixes#4020
Resolved by the keeper:
- ATTRIBUTION.toml: upstream's copy, hashes recomputed
- crates/organize/src/tests.rs: additions from both sides, upstream's first
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A bookmark made in the app always went to the top of the page. It now goes to
the top-left of the selected text, or with nothing selected to the top of the
window, as /XYZ left top null: the reader's zoom is kept, and with only the top
pinned so is the horizontal position.
- render: PageInfo::xyz_for_view, the inverse of dest_fraction. It pins only
the coordinates given; on a page turned a quarter the displayed y decides the
user left and the displayed x the user top.
- organize: add_bookmark_at and OutlineError::BadPosition (a coordinate that is
not finite is refused before anything changes).
- engine: Edit::AddBookmark takes left and top (None, None is the page top, as
before).
- automation: bookmark_add takes top and left in points from the top-left of the
displayed page, as bookmark_list reports them, bounded and refused when not a
usable number.
- ui: DocView::bookmark_position. It follows the window's vertical when the view
is turned a quarter (View > Rotate View), picks the selection's corner that
shows at the top-left, and in a spread of unequal pages uses the page that
crosses the window's top edge.
- parity: organize.bookmark-add evidence and what is still missing.
import_pages_with and insert_file_pages took the objects numbered above the
destination's highest number for the ones the import added. When no number
is left above the highest (an exhausted /Size, or object 4294967295),
Document::add fills free numbers below it: the copies skipped resource
sharing, and a refused import left its objects in the destination. With
object 4294967295 the `n + 1` overflowed, a panic in debug builds and in
release a wrap to 0 that counted every object as added.
Record the object numbers before the import and take the ones present
afterwards but not before.
Read all six boolean ViewerPreferences through the document resolver. Synthetic regressions cover direct/indirect dictionaries, true/false, malformed values and save/reopen.
The root's MediaBox and the page's own both have four numbers, so the
length check passed even if the page lost its own value. Compare the
values instead.
annot::page_refs, the forms page_refs and organize's walk queued every
/Kids entry before the seen check, and organize cloned the inherited
attributes, Resources included, for each queued entry. A page tree whose
/Kids lists the same child many times, or whose nodes share one /Kids
array, held all of those entries at once before they were dropped as
already seen.
Each walk is now a small iterator with one cursor per open /Kids array,
the way pdfcraft_model::pages has walked since #1926. organize carries
inherited attributes as the nodes that set them and copies them only for
the pages it returns. Page order, the pages found, the inherited values
and the million-node cap are unchanged.
attachment_add compared every attachment name, including files attached to pages as comments, so a page comment with the same file name made the reported key empty. It now compares only the document-level name-tree keys. Deleting the last document attachment when the catalog's /Names is an indirect dictionary left an empty /Names behind; it is now removed from the catalog, as the direct case already did.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: ATTRIBUTION.toml, crates/organize/src/tests.rs (code resolved by hand: crates/organize/src/tests.rs). Catalogs keep upstream's rows, then this branch's rows upstream lacks; ATTRIBUTION.toml is upstream's with every sha256 recomputed; progress lines keep both sides, upstream's first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M4: Ctrl-drag copies pages in the Organize grid (#2997)
Dragging thumbnails in the Organize page grid with Ctrl held (Option on
macOS) drops copies at the gap instead of moving the pages: a "+" badge
by the pointer, the copies land at the gap selected, and one undo step
takes them out. The insertion point joins the engine edit
(Edit::DuplicatePages { at }) so the copy is one undo step rather than a
duplicate plus a move.
page_duplicate takes a 1-based "at" (the first copy becomes page "at"),
so agents can drive the same thing, and the UI control channel's
ui.drag modifiers now reach the input state -- which gestures that read
it (this copy-drag, and Alt-drag column select) need.
Closes#2997.
* fix(ui): take the grid's copy modifier from the runtime OS (#3092 review)
Option-drag in the Organize grid moved the pages instead of copying them in a Mac
browser: `cfg!(target_os = "macos")` is false in the wasm build, and eframe sets the
context's OS from the browser. `copy_modifier` now takes the context and asks
`commands::mac_shortcuts`, as the shortcut labels do.
The drag helpers in `tests/editing.rs` and `tests/control.rs` picked the modifier the
same compile-time way, so they would have sent Ctrl on a Mac host; both read the
context's OS now. The new regression test
`option_copies_and_ctrl_moves_when_the_context_reports_macos` drives a Mac context —
Ctrl moves, Option copies — and fails without this change.
* M4: custom document properties through the engine and the tools (#organize.custom-properties)
/Info entries beyond the ones the spec names are custom properties:
organize lists them (custom_info) and set_info accepts any such name
after Acrobat's checks (no standard name, no empty name, no spaces,
delimiters, / or #, at most 127 bytes); inspection lists them too
(DocInfo::custom); doc_info reports them under custom and doc_set_info
sets or clears them. The Custom tab in Document Properties and the XMP
mirror stay planned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom document properties list the same before and after an edit
Review follow-up. The open-time reader (render/inspect.rs) now uses the
engine's rule (pdfcraft_organize::custom_info, trimmed, empty values
dropped): text strings only, NUL/whitespace trimmed, empty values left out,
and the 256 cap counting listed entries. Before, it also listed name
values (`/Dept /RnD`) and capped before filtering, so the list could change
after the first unrelated edit. An automation test opens a file with a name
value, blank, NUL-padded, empty and numeric entries and checks doc_info's
custom list is the same before and after setting the Title.
Drops the ROADMAP.md log line, which conflicted with main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M4: custom properties drop NUL/whitespace padding in one pass, before the cap
Review follow-up. custom_info now trims NULs and whitespace in any mix
(" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does
both before taking 256, so blank entries can't crowd real ones out. It is
the one rule: the engine uses it as it is, and the open-time reader trims
the same way. Tests: 300 blank entries before a real one, and mixed
padding, in organize and through doc_info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>