From f74c19de871b96dea05ee468f3bafa0327b5e39a Mon Sep 17 00:00:00 2001 From: taylorandrew07-lab Date: Sat, 10 Oct 2026 17:01:08 -0400 Subject: [PATCH] Combine files: move and remove single pages before combining (3 of 3, closes #611) (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Combine files: a thumbnail grid, card sizes and card actions (refs #611) Combine files shows each file as a card (the default; Grid/List kept in the settings, --combine-view): the first page it adds, stacked when it adds more, its name shortened in the middle. Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar, selection and history. Locked, unreadable, bad-range and unmeasurable files say why. Thumbnails render off the UI thread, only for cards in view: at most three render threads at once, counted until they exit (no watchdog replacements), a 32 MB texture budget, keys that never show another page, password or file, and one heavy step per frame on the web. Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl +/-/0, Ctrl+wheel or pinch over the grid, kept in the settings, --combine-zoom), keeping the card under the pointer in place. Hovering a card shows its actions over the top of its page: remove that file, and a magnifier that shows it large, flipping through the pages it adds; the same on a right click, and Space by keyboard. The toolbar wraps between groups at narrow widths. RenderPool gains retire(), without_replacements() and threads() so a caller can bound the render threads that exist at once. Co-Authored-By: Claude Opus 5.5 * Combine files: expand a file into its pages (refs #611) A file that adds more than one page (up to 2000) expands into a card per page it adds, in its range's order, each labelled with its page and the file's name, on a shared tinted band: from its card's hover bar, a right click, or Expand all / Collapse all in the toolbar. A page card's magnifier opens the preview on that page; Up and Down step rows of cards. Expanding is a view: not an undo step, kept across views and zoom, and the combined file is the same either way. Thumbnails are now kept per file and page, looked up on demand (the cost follows the files, not the pages of an expanded file), and a render's document stays open for two seconds so the next page of the same file isn't read again. Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION). Co-Authored-By: Claude Opus 5.5 * Combine files: move and remove single pages before combining (closes #611) With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere and removed (a page card's trash, the toolbar, Delete), each one undo step; the files themselves are never changed. A page moved between another file's pages splits its file into parts that share its bytes: the list shows each part as a row with its own page range, and parts that end up side by side join again. A split file still counts, unlocks and combines as one file. organize gains combine_grouped (and Session::combine_grouped): each file is copied once and its pages put in the order shown, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its fields and one copy of its attachments. A page listed twice now gets its own copies of its annotations, and a form field's widget on it joins the same field, so the field keeps one value. The existing combine is unchanged; doc_combine gains an optional groups array (entries with the same number are one file split into parts), so the same combine is reachable headless. Co-Authored-By: Claude Opus 5.5 * Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 * Measure: snap to the implicit closing edge of filled paths (#803) (#862) * fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833) Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success. Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not. Fixes #801. Co-authored-by: Big Pickle * Autosave: snapshot a new unsaved document before its first edit (#815) (#850) * OCR: Recognize text in multiple files refuses paths outside the root (#795) ocr_recognize_files turned a path outside the automation root into a per-file error and returned success, so no_tool_escapes_the_root reported 9 leaks once the OCR models were installed. Without them, loading the models failed first and hid it, and CI never installs them. Every path is now checked before any work starts, and one outside the root refuses the whole call, like the other tools. A missing file inside the root stays a per-file error. Nothing was ever read from outside the root. ocr_recognize_files_refuses_paths_outside_the_root checks this without the models. Co-authored-by: Claude Opus 5.5 * fix(text): match phrases across repeated whitespace (#814) (#831) The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected. Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries. Fixes #814. Co-authored-by: Big Pickle * fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838) Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank. Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before. The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost. Co-authored-by: Big Pickle * fix(comments): keep a reply to a reply in the comment listing (#806) (#839) comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread. Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies. The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix. Co-authored-by: Big Pickle * Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836) Acrobat reports an encrypted PDF as "damaged and could not be repaired", before asking for the password, when the document catalog sits in an object stream. Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such files, and qpdf keeps the catalog standalone too. Full saves of encrypted documents now write the catalog as its own object; unencrypted output is unchanged. Verified with Acrobat DC on macOS: the same file fails with the catalog packed and opens with it standalone, in AES-256 and RC4-128. Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * fix(edit): refuse a header start number that would wrap the next page (#804) (#834) Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead. Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run. Fixes #804. Co-authored-by: Big Pickle * OCR: a scanned euro sign reads as €, not E (#796) The ocrs crate's default alphabet has an E where its recognition model was trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was trained with. the_alphabet_is_the_trained_one checks the alphabet without the models; recognize_text_reads_the_euro_sign reads a scan end to end when they are installed. Co-authored-by: Claude Opus 5.5 * fix(comments): include replies to replies in the comment summary (#820) (#840) The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it. Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged. The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix. Co-authored-by: Big Pickle * fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860) * fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835) * view: check Shift-Command-G before Command-G for find navigation (#810) * Tests: the find shortcut test waits for find by a deadline (cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37) --------- Co-authored-by: Brandon Thomas * fix(editing): recognize paragraph drafts as unsaved work (#811) (#843) * view: check Shift-Command-G before Command-G for find navigation (#810) * fix: recognize paragraph drafts as unsaved work (Issue #811) --------- Co-authored-by: Brandon Thomas * fix(automation): retain batch results with identical filenames (#877) * fix(automation): retain batch results with identical filenames * Format --------- Co-authored-by: Brandon Thomas * M13: preserve desktop Action Wizard batch results (#886) * M13: preserve desktop Action Wizard batch results Co-Authored-By: Codex * ROADMAP: date the desktop action outputs entry today --------- Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas * Fit to width fits the shown page in single-page view (#808) (#859) * fix(links): find URLs when lowercasing changes the text length (#816) (#830) find_urls case-folded a copy of the page text and then sliced the original characters with offsets taken from that copy. Case folding can change a string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI exited 101 before it could create the link; MCP reported an internal tool error but kept serving. Match the ASCII prefixes case-insensitively against the original characters instead, so every offset stays in the source's own index space, and use get() so no lookup can index past the end. A new test reproduces the reported panic on the old code (range start index 41 out of range for slice of length 39) and passes with the fix. pdfcraft-annot's tests, clippy and rustfmt are green. Fixes #816. Co-authored-by: Big Pickle * organize: deleting the opening page drops the catalog OpenAction (#821) (#855) * fix(text): Turkish dotted and dotless i match in case-insensitive search (#892) Case-insensitive search folded the query and the page with str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a combining dot above (two characters) and U+0131 (ı) has no case pair. So "istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found "IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find, Search & Redact (phrases and word lists) and the automation text search, so Turkish words could also be left unredacted. Fold I, i, İ and ı to "i" and every other character with its own lowercase, one character for one character. Case-sensitive search is unchanged. * XFDF keeps polygon and polyline vertices (#809) (#848) Export wrote polygon and polyline comments without their points and import had nowhere to read them from, so an XFDF round trip left the comments with no /Vertices and no appearance. Export now writes the points as XFDF x,y;x,y;…, and import reads them back (whole x,y pairs only) before the appearance is drawn. Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854) Co-authored-by: Brandon Thomas * Web: warn before reloading a document with unsaved changes (#812) (#856) * Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858) Recovering an encrypted snapshot parked its metadata in `pending_recovered` until the password prompt was answered. Cancel left it there, and the next encrypted document unlocked in the foreground took the snapshot's original path and recovery key: Save would overwrite the other file and delete its snapshot. The association now travels with the prompt (`PasswordPrompt::recovered`). Cancel puts the entry back on offer and leaves the store alone; a wrong password keeps it on the re-raised prompt; the correct one finishes the recovery of the tab that was just opened. `pending_recovered` is gone. Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted B with its password. B keeps its own path and is clean, A's snapshot stays in the store, also after closing B. Fails on main at "B keeps its own path". Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868) A precision touchpad sends pixel-precise `MouseWheel` events. On Windows they arrive with no `TouchPhase::Start`, so egui never takes its no-smoothing "in touch" path and eases any delta of 8 px or more over several frames, leaving the page a frame behind the finger. The page view now applies precise (Point-unit) wheel input itself, 1:1, through the same un-eased scroll path as keyboard and middle-button auto-scroll, and suppresses egui's eased copy for a short window so its internal easing tail neither doubles nor trails the motion. A coarse mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant for chunky notches, and single-page paging still owns the vertical axis. Regression test: one 200 px precise swipe scrolls the full 200 px at once (an eased path would ramp there over ~0.1 s) and leaves no drift. Co-authored-by: Justin John Co-authored-by: Claude Opus 4.8 * Keep every part when bookmark titles repeat in doc_split (#800) (#863) * Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769) Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a release file's SHA-256, Authenticode status and, with an external YARA binary and rule, every matched string identifier and offset. Document the procedure in docs/releasing.md ("Scanner detections"). No application, dependency, installer or network behaviour changes: the diff touches only the helper and the release documentation. Co-authored-by: Claude Haiku 5.5 * Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832) The notice bar above the pages (security, forms, XFA, repaired file, warnings, signatures) laid its message out before its buttons. In a narrow document area (a side panel open, a longer translation) the message ran under "Security settings" and on into the side panel, and it wasn't centred on the buttons. A shared notice_bar helper now lays the buttons out first, right to left, centred on a 28 px row, and gives the message the width that's left: it wraps there (cut short with the full text on hover when less than 120 px is left) and its first line is centred on the buttons' row. Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them (one line, wrapped and cut-short widths; fails on main). Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 * M10.2: scan JPEG metadata beyond frame headers (#764) Co-authored-by: Codex Co-authored-by: Brandon Thomas * Issue #878: Combine stores an image once when its colour space is an indirect name (#883) ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`, `10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it isn't a resource, so identical images compared unequal and every copy was kept: 8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB. Dedupe now compares a reference to a plain value (name, number, boolean or string) as that value. The value objects themselves are still never merged. Combine, Insert Pages and Reduce File Size share this code. Co-authored-by: Claude Opus 5.5 * organize: drop named destinations and their links when a page is deleted (#817) (#851) * JavaScript: limit util.printf widths and output, and array arguments (#770) util.printf accumulated a conversion's width and precision with unchecked arithmetic and no maximum, and its output had no bound; the array arguments of resetForm and the field value/colour setters were read for as many items as the array's length says, which a sparse array makes free. Saturate the numbers and refuse widths and precisions above 4096, output above 1 MiB and arrays longer than 2^20 with a script error, before anything is allocated from them. Co-authored-by: Claude Sonnet 5.5 * Linux: "Use system setting" asks the desktop, not winit (#881) winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` there on every desktop — so `ThemePreference::System` never saw the desktop's choice and silently fell back to whatever was on screen: PdfCraft came up light on a dark desktop, and picking "Use system setting" after Dark kept it dark. The default setting did not follow the system. `pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off the UI thread and the app follows the answer, including a change made while it runs. The colour scheme is asked for under its GNOME schema name first, which GNOME's and XFCE's portals both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone, exactly as before. Co-authored-by: GyuriX * Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852) * M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822) A signature placed with Fill & Sign could only be moved with the Select tool. While a Fill & Sign tool was active (Sign, Initials, Add text, Check, Date...), hovering the signature showed the placement crosshair, a drag did nothing and a click stamped another mark on top of it. Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks already on the page: hovering one shows the move cursor, a click selects it, a drag moves it and the selected mark's handles resize it. Away from them the tool places as before, including on other comments. Image signatures keep their live move/resize preview. Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for the engine's list, the same rules in the viewer's inspector), so this also works on reopened files. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 * Web: opt-in embedding bridge for host pages (?host=parent) (#842) A page that frames PdfCraft on the same origin can now pass it PDFs and receive saves through postMessage, instead of Save becoming a browser download. Built for a Nextcloud integration, but nothing in it is host-specific. - ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`), None by default. On wasm, Save hands the bytes to `host_save` when set; `host_dirty` hears each frame whether any tab has unsaved work. - pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a frame. Messages: pdfcraft:ready / open / save / dirty / failed; only the parent window on our own origin is heard or answered. `?author=` names comment authors unless the user chose a name. - Test: host_dirty_hears_when_unsaved_work_appears_and_goes. Without `?host=parent` nothing changes. Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861) * Single instance: enable tab handoff in Windows portable mode (#889) (#891) * Single instance: enable tab handoff in Windows portable mode (#889) * Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name (cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872) --------- Co-authored-by: Brandon Thomas * Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823) * Issue #390: build the DMG with hdiutil create so the app passes codesign --strict makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every file in the image, and codesign --verify --deep --strict rejects that as "Finder information, or similar detritus", on the app in the DMG and on a copy installed from it. hdiutil create -srcfolder writes the files as they are; it attaches a device while copying, so it gets up to five tries. A gate mounts the finished image read-only and verifies the app inside with --strict. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu * Issue #390: retry hdiutil attach and detach in the DMG gate too As suggested in review: the gate mounts the signed image under set -e, so a "Resource busy" there would stop a release after the DMG was built. The same five tries with growing pauses now cover create, attach and detach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu --------- Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * M4: make page-label generation lazy across large documents (#845) * M4: make page-label generation lazy across large documents Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count. The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents. Co-Authored-By: Claude Haiku 5.5 * M4: drop the aggregate page-label cap from document inspection Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted. MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes. Co-Authored-By: Claude Haiku 5.5 * M4: keep the inspector's 4 MiB page-label cap until its labels are lazy Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it. Co-Authored-By: Claude Haiku 5.5 --------- Co-authored-by: Claude Haiku 5.5 * lopdf: bound object lookups through compressed containers and object-stream members (#771) A compressed object's container was resolved with a fresh cycle set and without checking that it is an ordinary object, and indirect /Length chains had no depth limit, so self-containing or chained lookups overflowed the stack. Pass the caller's set on, require an in-file container and cap the chain at 16 lookups. Object-stream members were each parsed to the end of the decoded stream, so repeated or nested offsets kept many copies of one large object: parse each member within its own extent and skip repeated offsets. Co-authored-by: Claude Sonnet 5.5 * hayro-syntax: cap the nesting of direct arrays and dictionaries (#772) Skipping a direct array or dictionary, and the lenient repair of a dictionary that has an object where a key belongs, recursed once per level with no bound, so deep nesting in a trailer entry or a content stream overflowed the stack. Count the depth on the Reader and fail beyond 128 levels like any other malformed object. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas * M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849) Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page images. Reduce skipped every image with a decode array, so a scan stayed the same size. The default array is now treated as absent; any other array still skips the image. Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller". It now saves nothing and says the file is already as small as it can be made; doc_reduce returns "written": false. Optimize PDF still saves (what it discards may matter more than the size) but says "not smaller". Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894) Deleting an image with Edit a PDF removes its Do operation, but the image stays in the page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF) carried its data along: a page left blank still weighed the whole picture. Clean Up now drops image and form XObjects from a page's own resources when their name is written in no stream the document still uses. Shared and inherited resources are left alone, and if any stream can't be read the way a viewer would read it, nothing is removed: viewers look names up through parent resources, so only a name written nowhere is safe to drop. doc_optimize reports unused_xobjects. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 * M9: name the Windows store certificates that can't sign (#179) (#761) A store certificate PdfCraft can't use (no private key, a P-521 / Brainpool / Ed25519 key, a key CNG can't open) was dropped from the Sign dialog silently, so a user whose CA-issued identity shows in Acrobat but not here had nothing to go on. `windows::list` now returns those under `unusable` with the subject, fingerprint and a reason that names the Windows error code; the Sign dialog shows them greyed and unselectable with the reason, and `sign_windows_ids` returns them. A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED while listing) is listed and prompts at signing instead of being skipped. Certificates without a private key stay out of the dialog but are reported by the tool. Tests: the explained-error table, the store listing's invariants, a key-less certificate in the ignored store test, the automation output, and a kittest dialog test for a greyed entry. Verified by hand that a .p12 imported into the CNG and legacy Microsoft providers lists, and that a P-521 certificate is explained. Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas * M12: XFA rows in areas get their instances, and areas stay out of data paths (#853) A Designer form whose table (a top-to-bottom subform inside an area of a positioned page) asks for several rows with `` was laid out with one row, and a FormCalc total looping over the rows stayed empty. - layout: a repeating subform inside a top-to-bottom container in positioned content is drawn and measured once per instance (occur and data), with its own SOM index, as flowed content already was. One `instances` helper now serves flowing, table rows and this case. - data: areas are named in SOM expressions but are not data scopes. `data_path(tpl, som)` leaves them out (unknown segments are kept), and layout, the script tree, reading and writing the datasets and the engine's value and row operations use it. Before, values Adobe's viewers saved were not read, what was filled here went under a new element named after the area (which Adobe's viewers would most likely not read), and scripts counted the rows there: one, so a loop over the table's rows failed on the missing ones. - write_datasets_with / read_values_with take the parsed template when the caller has it (the engine's cached one); the old functions parse the document's. - Tests: layout regression, data_path rules, and an engine round trip shaped like such a form (rows, Adobe-written values, a FormCalc total over every row, the data written back, reopening). Signed-off-by: Hadi Chokr Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Exported images keep their transparency, JPEG cut-outs included (#645) (#763) * M10: exported images keep their transparency, JPEG cut-outs included (#645) Export all images and Save image as wrote a JPEG with a soft mask as the bare JPEG and dropped soft masks of another size, JPEG-compressed soft masks, stencil /Mask images and colour-key /Mask arrays, so cut-out images came out on a solid white or black background. - An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is written as a PNG with it as alpha, JPEG images included (decoded with the image crate's JPEG decoder, already in the tree). JPEGs without transparency are still written byte for byte. - Soft and stencil masks of another size are scaled to the image's size; /Decode inversion is honoured and /Matte pre-blending is undone. - Image sizes are checked (positive, at most 100 Mpx) before use, so a negative /Width no longer overflows in Save image as. - Regression tests: JPEG + soft mask, scaled soft mask, stencil mask, colour key, matte, and damaged masks/sizes (all fail or panic on main). Fixes #645 Co-Authored-By: Claude Opus 5.5 * M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645) Rust 1.99's clippy denies chunks_exact with a constant size, which failed the Clippy step on all CI targets. as_chunks::<2> also drops the indexing. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Fill colour for rectangle, oval and polygon comments (#686) (#793) * M5: fill colour for rectangle, oval and polygon comments (#686) Rectangles, ovals, polygons and clouds could only be given a border colour. They can now be filled, or have their fill removed: - the comment quick bar has a Fill colour control for those tools ("No fill" or a swatch), kept with the other per-tool styles; - Comment Properties > Appearance has a Fill colour row, applied in the same undo step as the other changes and carried over by Make Current Properties Default; - comment_edit takes `fill` (a colour, or "none"). pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the appearance; lines, ink and locked comments refuse it) and reports `fillable`/`fill` in the comment properties. The engine adds Edit::FillAnnotation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN * M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686) The fill-colour strings changed 13 translation catalogs, so xtask's the_real_manifest_passes failed on ubuntu and windows CI. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * M14: localize displayed shortcut key names (#496) (#884) Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas * M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765) As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, instead of everything between two points in reading order. It works with the Select tool, the text markup tools and Redact, so a column can be copied, highlighted or redacted. - render: PageText::glyphs_in (glyph centre inside the rectangle), glyph_rects (one box per run, never covering unselected text) and column_text: rows top to bottom, side-by-side pieces separated by a tab, right to left in right-to-left rows, reading order for vertical text. - ui-egui: a selection is a reading-order run or a column rectangle; the rectangle is outlined while dragging and the cursor is a crosshair. ⇧-click clears a column selection (it has no anchor glyph). - Edit ▸ Column select (edit.column_select, quick tool "column-select") does it with a plain drag. It is the way in on Linux desktops whose window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE, KDE Plasma 5 by default), where the app never sees the drag. - automation: text_extract takes rect for the same extraction headless. - "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml hashes updated. - parity: new view.column-select entry with platform notes and gaps (no selection across pages, no ⇧-click extension). Windows: winit hands an Alt release to the system menu loop only when the window has a native menu, which PdfCraft's does not, so Alt-drag cannot leave the window in menu mode. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777) Combine files shows each file as a card (the default; Grid/List kept in the settings, --combine-view): the first page it adds, stacked when it adds more, its name shortened in the middle. Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar, selection and history. Locked, unreadable, bad-range and unmeasurable files say why. Thumbnails render off the UI thread, only for cards in view: at most three render threads at once, counted until they exit (no watchdog replacements), a 32 MB texture budget, keys that never show another page, password or file, and one heavy step per frame on the web. Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl +/-/0, Ctrl+wheel or pinch over the grid, kept in the settings, --combine-zoom), keeping the card under the pointer in place. Hovering a card shows its actions over the top of its page: remove that file, and a magnifier that shows it large, flipping through the pages it adds; the same on a right click, and Space by keyboard. The toolbar wraps between groups at narrow widths. RenderPool gains retire(), without_replacements() and threads() so a caller can bound the render threads that exist at once. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Combine files: expand a file into its pages (2 of 3, refs #611) (#788) * Combine files: a thumbnail grid, card sizes and card actions (refs #611) Combine files shows each file as a card (the default; Grid/List kept in the settings, --combine-view): the first page it adds, stacked when it adds more, its name shortened in the middle. Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar, selection and history. Locked, unreadable, bad-range and unmeasurable files say why. Thumbnails render off the UI thread, only for cards in view: at most three render threads at once, counted until they exit (no watchdog replacements), a 32 MB texture budget, keys that never show another page, password or file, and one heavy step per frame on the web. Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl +/-/0, Ctrl+wheel or pinch over the grid, kept in the settings, --combine-zoom), keeping the card under the pointer in place. Hovering a card shows its actions over the top of its page: remove that file, and a magnifier that shows it large, flipping through the pages it adds; the same on a right click, and Space by keyboard. The toolbar wraps between groups at narrow widths. RenderPool gains retire(), without_replacements() and threads() so a caller can bound the render threads that exist at once. Co-Authored-By: Claude Opus 5.5 * Combine files: expand a file into its pages (refs #611) A file that adds more than one page (up to 2000) expands into a card per page it adds, in its range's order, each labelled with its page and the file's name, on a shared tinted band: from its card's hover bar, a right click, or Expand all / Collapse all in the toolbar. A page card's magnifier opens the preview on that page; Up and Down step rows of cards. Expanding is a view: not an undo step, kept across views and zoom, and the combined file is the same either way. Thumbnails are now kept per file and page, looked up on demand (the cost follows the files, not the pages of an expanded file), and a render's document stays open for two seconds so the next page of the same file isn't read again. Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION). Co-Authored-By: Claude Opus 5.5 * Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#873) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, PDF-spec, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 * Measure: snap to the implicit closing edge of filled paths (#803) (#862) * fix(organize): reject rotation angles that are not multiples of 90 (#801) (#833) Page rotation only turns in quarter steps, but the degree value was read with an integer division by 90 that threw the remainder away. Asking for 45 degrees saved an unrotated page, 91 and 135 saved a 90 degree turn, and the command still reported success. Reject the value before touching the document instead, so every caller gets an actionable error and the pages are left alone. The automation tool already rejected it; the single edit command did not. Fixes #801. Co-authored-by: Big Pickle * Autosave: snapshot a new unsaved document before its first edit (#815) (#850) * OCR: Recognize text in multiple files refuses paths outside the root (#795) ocr_recognize_files turned a path outside the automation root into a per-file error and returned success, so no_tool_escapes_the_root reported 9 leaks once the OCR models were installed. Without them, loading the models failed first and hid it, and CI never installs them. Every path is now checked before any work starts, and one outside the root refuses the whole call, like the other tools. A missing file inside the root stays a per-file error. Nothing was ever read from outside the root. ocr_recognize_files_refuses_paths_outside_the_root checks this without the models. Co-authored-by: Claude Opus 5.5 * fix(text): match phrases across repeated whitespace (#814) (#831) The phrase search collapsed runs of whitespace in the query but not in the page text it was matching against, so a document drawn with two space glyphs in a row like the issue example never matched the phrase. Single words still matched, which is why it looked like only some files were affected. Collapse whitespace while the text is flattened, the same way the query is normalised. The regression test fails on the old code because the phrase find returns an empty range, and it also covers tabs and single word queries. Fixes #814. Co-authored-by: Big Pickle * fix(edit): keep a comment whose appearance is missing when flattening (#805) (#838) Viewers draw notes, FreeText, ink and stamps from their dictionary when the file stores no /AP /N. flatten only drew annotations that already had an appearance and then removed the rest, so a supported note disappeared and the saved page came back blank. Build the appearance a viewer would show for every selected, visible, non-widget annotation that has none, then draw it as usual. A subtype this build cannot render still fails to build and is handled exactly as before. The new synthetic test draws the note icon into the page and asserts it is baked in; without the fix flatten reports 0 drawn and the icon is lost. Co-authored-by: Big Pickle * fix(comments): keep a reply to a reply in the comment listing (#806) (#839) comment_list collected only the replies whose /IRT pointed at the root comment, so a reply to a reply was listed nowhere at all. Roots and the root count were already right; the nested reply was simply dropped from its thread. Follow the /IRT chain to any depth, with a bound, so every reply under a comment is listed in document order. A reply to a reply now stays discoverable through the tool that promises every comment and its replies. The new test builds a root note, a direct reply and a reply to that reply, then checks both texts are listed and the two notes stay roots. It listed only the direct reply before the fix. Co-authored-by: Big Pickle * Issue #774: keep the catalog out of object streams in encrypted files (Acrobat) (#836) Acrobat reports an encrypted PDF as "damaged and could not be repaired", before asking for the password, when the document catalog sits in an object stream. Every other reader tried (qpdf, pypdf, poppler, PDFKit, PdfCraft) accepts such files, and qpdf keeps the catalog standalone too. Full saves of encrypted documents now write the catalog as its own object; unencrypted output is unchanged. Verified with Acrobat DC on macOS: the same file fails with the catalog packed and opens with it standalone, in AES-256 and RC4-128. Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * fix(edit): refuse a header start number that would wrap the next page (#804) (#834) Page numbering adds the start number to the position of the page in the run. At the top of the u32 range that addition wrapped, so a release build saved zero for the second page and still reported success, and a debug build hit a caught overflow error instead. Check the whole run before drawing anything. When the last page number would pass the u32 limit the operation returns an actionable error and the document keeps no header, rather than a half applied run. Fixes #804. Co-authored-by: Big Pickle * OCR: a scanned euro sign reads as €, not E (#796) The ocrs crate's default alphabet has an E where its recognition model was trained with € (ocrs-models DEFAULT_ALPHABET), so a scanned "€250" was recognised as "E250". pdfcraft-ocr now gives ocrs the alphabet the model was trained with. the_alphabet_is_the_trained_one checks the alphabet without the models; recognize_text_reads_the_euro_sign reads a scan end to end when they are installed. Co-authored-by: Claude Opus 5.5 * fix(comments): include replies to replies in the comment summary (#820) (#840) The comment summary gathered a comment's direct replies only, matching /IRT against the comment itself. A reply to a reply points at the direct reply, so its text never reached the generated summary even though the stored discussion kept it. Replies are now walked to any depth, with a bound, and indented one level per reply. The existing output for direct replies is unchanged. The new test builds a rectangle, a reply and a reply to that reply, then checks the summary carries both texts with the nested one indented under its parent. It omitted the nested reply before the fix. Co-authored-by: Big Pickle * fix(xfdf): drop the replaced note's popup when re-importing a comment (#807) (#860) * fix(view): check Shift-Command-G before Command-G for find navigation (#810) (#835) * view: check Shift-Command-G before Command-G for find navigation (#810) * Tests: the find shortcut test waits for find by a deadline (cherry picked from commit eaf3180bd8ebb353b3d8c5ca7b9ef7548ed55c37) --------- Co-authored-by: Brandon Thomas * fix(editing): recognize paragraph drafts as unsaved work (#811) (#843) * view: check Shift-Command-G before Command-G for find navigation (#810) * fix: recognize paragraph drafts as unsaved work (Issue #811) --------- Co-authored-by: Brandon Thomas * fix(automation): retain batch results with identical filenames (#877) * fix(automation): retain batch results with identical filenames * Format --------- Co-authored-by: Brandon Thomas * M13: preserve desktop Action Wizard batch results (#886) * M13: preserve desktop Action Wizard batch results Co-Authored-By: Codex * ROADMAP: date the desktop action outputs entry today --------- Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas * Fit to width fits the shown page in single-page view (#808) (#859) * fix(links): find URLs when lowercasing changes the text length (#816) (#830) find_urls case-folded a copy of the page text and then sliced the original characters with offsets taken from that copy. Case folding can change a string's length — U+0130 (İ) lowercases to "i" plus a combining dot — so the offsets drifted past the end of the source and `chars[i..j]` panicked. The CLI exited 101 before it could create the link; MCP reported an internal tool error but kept serving. Match the ASCII prefixes case-insensitively against the original characters instead, so every offset stays in the source's own index space, and use get() so no lookup can index past the end. A new test reproduces the reported panic on the old code (range start index 41 out of range for slice of length 39) and passes with the fix. pdfcraft-annot's tests, clippy and rustfmt are green. Fixes #816. Co-authored-by: Big Pickle * organize: deleting the opening page drops the catalog OpenAction (#821) (#855) * fix(text): Turkish dotted and dotless i match in case-insensitive search (#892) Case-insensitive search folded the query and the page with str::to_lowercase, which follows no locale: U+0130 (İ) becomes "i" plus a combining dot above (two characters) and U+0131 (ı) has no case pair. So "istanbul" never found "İSTANBUL", and "ırmak"/"kış" never found "IRMAK"/"KIŞ" (or the reverse). The same PageText::find serves Find, Search & Redact (phrases and word lists) and the automation text search, so Turkish words could also be left unredacted. Fold I, i, İ and ı to "i" and every other character with its own lowercase, one character for one character. Case-sensitive search is unchanged. * XFDF keeps polygon and polyline vertices (#809) (#848) Export wrote polygon and polyline comments without their points and import had nowhere to read them from, so an XFDF round trip left the comments with no /Vertices and no appearance. Export now writes the points as XFDF x,y;x,y;…, and import reads them back (whole x,y pairs only) before the appearance is drawn. Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854) Co-authored-by: Brandon Thomas * Web: warn before reloading a document with unsaved changes (#812) (#856) * Issue #813: a cancelled recovery password no longer attaches to the next unlocked document (#858) Recovering an encrypted snapshot parked its metadata in `pending_recovered` until the password prompt was answered. Cancel left it there, and the next encrypted document unlocked in the foreground took the snapshot's original path and recovery key: Save would overwrite the other file and delete its snapshot. The association now travels with the prompt (`PasswordPrompt::recovered`). Cancel puts the entry back on offer and leaves the store alone; a wrong password keeps it on the re-raised prompt; the correct one finishes the recovery of the tab that was just opened. `pending_recovered` is gone. Test: crash with an encrypted snapshot of A, Recover, Cancel, open encrypted B with its password. B keeps its own path and is clean, A's snapshot stays in the store, also after closing B. Fails on main at "B keeps its own path". Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * Viewer: touchpad scrolling tracks the finger 1:1 (#759) (#868) A precision touchpad sends pixel-precise `MouseWheel` events. On Windows they arrive with no `TouchPhase::Start`, so egui never takes its no-smoothing "in touch" path and eases any delta of 8 px or more over several frames, leaving the page a frame behind the finger. The page view now applies precise (Point-unit) wheel input itself, 1:1, through the same un-eased scroll path as keyboard and middle-button auto-scroll, and suppresses egui's eased copy for a short window so its internal easing tail neither doubles nor trails the motion. A coarse mouse wheel (Line/Page units) keeps egui's smoothing, which is pleasant for chunky notches, and single-page paging still owns the vertical axis. Regression test: one 200 px precise swipe scrolls the full 200 px at once (an eased path would ramp there over ~0.1 s) and leaves no drift. Co-authored-by: Justin John Co-authored-by: Claude Opus 4.8 * Keep every part when bookmark titles repeat in doc_split (#800) (#863) * Windows: add reproducible security diagnostics for storytold/pdfcraft#747 (#769) Add packaging/windows/security-audit.ps1, a read-only maintainer helper that records a release file's SHA-256, Authenticode status and, with an external YARA binary and rule, every matched string identifier and offset. Document the procedure in docs/releasing.md ("Scanner detections"). No application, dependency, installer or network behaviour changes: the diff touches only the helper and the release documentation. Co-authored-by: Claude Haiku 5.5 * Notice bar: the message wraps beside its buttons and lines up with them (#785) (#832) The notice bar above the pages (security, forms, XFA, repaired file, warnings, signatures) laid its message out before its buttons. In a narrow document area (a side panel open, a longer translation) the message ran under "Security settings" and on into the side panel, and it wasn't centred on the buttons. A shared notice_bar helper now lays the buttons out first, right to left, centred on a 28 px row, and gives the message the width that's left: it wraps there (cut short with the full text on hover when less than 120 px is left) and its first line is centred on the buttons' row. Regression test: editing::the_security_notice_wraps_beside_its_buttons_and_lines_up_with_them (one line, wrapped and cut-short widths; fails on main). Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 * M10.2: scan JPEG metadata beyond frame headers (#764) Co-authored-by: Codex Co-authored-by: Brandon Thomas * Issue #878: Combine stores an image once when its colour space is an indirect name (#883) ImageMagick writes an image's colour space as its own object (`/ColorSpace 10 0 R`, `10 0 obj /DeviceRGB`). Each source's copy of that object was never merged, since it isn't a resource, so identical images compared unequal and every copy was kept: 8 copies of an 80 MB ImageMagick PDF combined into 648 MB instead of 81 MB. Dedupe now compares a reference to a plain value (name, number, boolean or string) as that value. The value objects themselves are still never merged. Combine, Insert Pages and Reduce File Size share this code. Co-authored-by: Claude Opus 5.5 * organize: drop named destinations and their links when a page is deleted (#817) (#851) * JavaScript: limit util.printf widths and output, and array arguments (#770) util.printf accumulated a conversion's width and precision with unchecked arithmetic and no maximum, and its output had no bound; the array arguments of resetForm and the field value/colour setters were read for as many items as the array's length says, which a sparse array makes free. Saturate the numbers and refuse widths and precisions above 4096, output above 1 MiB and arrays longer than 2^20 with a script error, before anything is allocated from them. Co-authored-by: Claude Sonnet 5.5 * Linux: "Use system setting" asks the desktop, not winit (#881) winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` there on every desktop — so `ThemePreference::System` never saw the desktop's choice and silently fell back to whatever was on screen: PdfCraft came up light on a dark desktop, and picking "Use system setting" after Dark kept it dark. The default setting did not follow the system. `pdfcraft-platform` now reads the XDG Desktop Portal's Settings interface off the UI thread and the app follows the answer, including a change made while it runs. The colour scheme is asked for under its GNOME schema name first, which GNOME's and XFCE's portals both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone, exactly as before. Co-authored-by: GyuriX * Extract/Combine: a page repeated in one selection gets its own annotations (#818) (#852) * M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822) A signature placed with Fill & Sign could only be moved with the Select tool. While a Fill & Sign tool was active (Sign, Initials, Add text, Check, Date...), hovering the signature showed the placement crosshair, a drag did nothing and a click stamped another mark on top of it. Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks already on the page: hovering one shows the move cursor, a click selects it, a drag moves it and the selected mark's handles resize it. Away from them the tool places as before, including on other comments. Image signatures keep their live move/resize preview. Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for the engine's list, the same rules in the viewer's inspector), so this also works on reopened files. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 * Web: opt-in embedding bridge for host pages (?host=parent) (#842) A page that frames PdfCraft on the same origin can now pass it PDFs and receive saves through postMessage, instead of Save becoming a browser download. Built for a Nextcloud integration, but nothing in it is host-specific. - ui-egui: HostSaveFn/HostDirtyFn hooks (`host_save`, `host_dirty`), None by default. On wasm, Save hands the bytes to `host_save` when set; `host_dirty` hears each frame whether any tab has unsaved work. - pdfcraft-web: `host::Bridge`, enabled only by `?host=parent` inside a frame. Messages: pdfcraft:ready / open / save / dirty / failed; only the parent window on our own origin is heard or answered. `?author=` names comment authors unless the user chose a name. - Test: host_dirty_hears_when_unsaved_work_appears_and_goes. Without `?host=parent` nothing changes. Claude-Session: https://claude.ai/code/session_01G6GtUCaPiCMEQCN4QcdQnp Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Print: a scaled annotation appearance no longer prints larger than its rectangle (#802) (#861) * Single instance: enable tab handoff in Windows portable mode (#889) (#891) * Single instance: enable tab handoff in Windows portable mode (#889) * Single instance: a portable copy's socket stays in %LOCALAPPDATA%, under its own name (cherry picked from commit c7db1e80a374e80a11504efefbaac680dfa5e872) --------- Co-authored-by: Brandon Thomas * Issue #390: build the DMG with hdiutil create so the app passes codesign --strict (#823) * Issue #390: build the DMG with hdiutil create so the app passes codesign --strict makehybrid -hfs wrote a com.apple.FinderInfo (Finder location -1,-1) on every file in the image, and codesign --verify --deep --strict rejects that as "Finder information, or similar detritus", on the app in the DMG and on a copy installed from it. hdiutil create -srcfolder writes the files as they are; it attaches a device while copying, so it gets up to five tries. A gate mounts the finished image read-only and verifies the app inside with --strict. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu * Issue #390: retry hdiutil attach and detach in the DMG gate too As suggested in review: the gate mounts the signed image under set -e, so a "Resource busy" there would stop a release after the DMG was built. The same five tries with growing pauses now cover create, attach and detach. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu --------- Co-authored-by: Tristan von Kries Co-authored-by: Claude Fable 5.1 * M4: make page-label generation lazy across large documents (#845) * M4: make page-label generation lazy across large documents Page labels were expanded into one String per page, and the whole sequence was refused once the labels passed 4 MiB in total. A valid document with long labels could therefore neither be read nor edited. Add a PageLabels view over the label ranges: label(page) builds one label, and iter() builds the labels one at a time, so no string is kept per page. Edits validate each range from its largest number (Roman numerals by their thousands) instead of building every label, so the check no longer depends on the page count. The per-label length limit, the number-tree work and depth limits and the overflow checks are unchanged. page_labels() keeps its signature and returns the same labels as before for ordinary documents. Co-Authored-By: Claude Haiku 5.5 * M4: drop the aggregate page-label cap from document inspection Document inspection refused every custom label once their total passed 4 MiB, so a valid document with long labels showed physical page numbers and a warning. Labels are now applied to every page. The per-label length limit, the number-tree work, depth and cycle limits and the prefix bounds are unchanged, and labels are still applied only after every range is accepted. MAX_LABEL_TOTAL_BYTES is removed. The inspector keeps one label per page (PageInfo.label), so its label memory grows with the page count times the label length. The page count is bounded by the objects in the file, and each label by 1,024 bytes. Co-Authored-By: Claude Haiku 5.5 * M4: keep the inspector's 4 MiB page-label cap until its labels are lazy Commit a096ece removed the aggregate page-label cap from document inspection. Inspector::fill keeps one label String per page on open, and inspection does not read labels from the lazy PageLabels view yet, so a hostile file with a million labelled pages would allocate on the order of a gigabyte when it is opened (measured here at about 1.8 KiB per labelled page). This reverts that commit. The cap stays until inspection reads labels lazily, as a separate change. The organizer change in 5253124 does not depend on it. Co-Authored-By: Claude Haiku 5.5 --------- Co-authored-by: Claude Haiku 5.5 * lopdf: bound object lookups through compressed containers and object-stream members (#771) A compressed object's container was resolved with a fresh cycle set and without checking that it is an ordinary object, and indirect /Length chains had no depth limit, so self-containing or chained lookups overflowed the stack. Pass the caller's set on, require an in-file container and cap the chain at 16 lookups. Object-stream members were each parsed to the end of the decoded stream, so repeated or nested offsets kept many copies of one large object: parse each member within its own extent and skip repeated offsets. Co-authored-by: Claude Sonnet 5.5 * hayro-syntax: cap the nesting of direct arrays and dictionaries (#772) Skipping a direct array or dictionary, and the lenient repair of a dictionary that has an object where a key belongs, recursed once per level with no bound, so deep nesting in a trailer entry or a content stream overflowed the stack. Count the depth on the Reader and fail beyond 128 levels like any other malformed object. Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas * M11: Reduce file size shrinks office scans, and never saves a bigger copy (refs #490) (#849) Office scanners write the default decode array (/Decode [0 1 0 1 0 1]) on their page images. Reduce skipped every image with a decode array, so a scan stayed the same size. The default array is now treated as absent; any other array still skips the image. Reduce also saved its copy even when it wasn't smaller, reported as "-N% smaller". It now saves nothing and says the file is already as small as it can be made; doc_reduce returns "written": false. Optimize PDF still saves (what it discards may matter more than the size) but says "not smaller". Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894) Deleting an image with Edit a PDF removes its Do operation, but the image stays in the page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF) carried its data along: a page left blank still weighed the whole picture. Clean Up now drops image and form XObjects from a page's own resources when their name is written in no stream the document still uses. Shared and inherited resources are left alone, and if any stream can't be read the way a viewer would read it, nothing is removed: viewers look names up through parent resources, so only a name written nowhere is safe to drop. doc_optimize reports unused_xobjects. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 * M9: name the Windows store certificates that can't sign (#179) (#761) A store certificate PdfCraft can't use (no private key, a P-521 / Brainpool / Ed25519 key, a key CNG can't open) was dropped from the Sign dialog silently, so a user whose CA-issued identity shows in Acrobat but not here had nothing to go on. `windows::list` now returns those under `unusable` with the subject, fingerprint and a reason that names the Windows error code; the Sign dialog shows them greyed and unselectable with the reason, and `sign_windows_ids` returns them. A key that needs a prompt to open (NTE_SILENT_CONTEXT, NTE_UI_REQUIRED while listing) is listed and prompts at signing instead of being skipped. Certificates without a private key stay out of the dialog but are reported by the tool. Tests: the explained-error table, the store listing's invariants, a key-less certificate in the ignored store test, the automation output, and a kittest dialog test for a greyed entry. Verified by hand that a .p12 imported into the CNG and legacy Microsoft providers lists, and that a P-521 certificate is explained. Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas * M12: XFA rows in areas get their instances, and areas stay out of data paths (#853) A Designer form whose table (a top-to-bottom subform inside an area of a positioned page) asks for several rows with `` was laid out with one row, and a FormCalc total looping over the rows stayed empty. - layout: a repeating subform inside a top-to-bottom container in positioned content is drawn and measured once per instance (occur and data), with its own SOM index, as flowed content already was. One `instances` helper now serves flowing, table rows and this case. - data: areas are named in SOM expressions but are not data scopes. `data_path(tpl, som)` leaves them out (unknown segments are kept), and layout, the script tree, reading and writing the datasets and the engine's value and row operations use it. Before, values Adobe's viewers saved were not read, what was filled here went under a new element named after the area (which Adobe's viewers would most likely not read), and scripts counted the rows there: one, so a loop over the table's rows failed on the missing ones. - write_datasets_with / read_values_with take the parsed template when the caller has it (the engine's cached one); the old functions parse the document's. - Tests: layout regression, data_path rules, and an engine round trip shaped like such a form (rows, Adobe-written values, a FormCalc total over every row, the data written back, reopening). Signed-off-by: Hadi Chokr Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Exported images keep their transparency, JPEG cut-outs included (#645) (#763) * M10: exported images keep their transparency, JPEG cut-outs included (#645) Export all images and Save image as wrote a JPEG with a soft mask as the bare JPEG and dropped soft masks of another size, JPEG-compressed soft masks, stencil /Mask images and colour-key /Mask arrays, so cut-out images came out on a solid white or black background. - An image with transparency (/SMask, stencil /Mask, colour-key /Mask) is written as a PNG with it as alpha, JPEG images included (decoded with the image crate's JPEG decoder, already in the tree). JPEGs without transparency are still written byte for byte. - Soft and stencil masks of another size are scaled to the image's size; /Decode inversion is honoured and /Matte pre-blending is undone. - Image sizes are checked (positive, at most 100 Mpx) before use, so a negative /Width no longer overflows in Save image as. - Regression tests: JPEG + soft mask, scaled soft mask, stencil mask, colour key, matte, and damaged masks/sizes (all fail or panic on main). Fixes #645 Co-Authored-By: Claude Opus 5.5 * M10: use as_chunks for colour-key pairs (clippy 1.99 chunks_exact_to_as_chunks) (#645) Rust 1.99's clippy denies chunks_exact with a constant size, which failed the Clippy step on all CI targets. as_chunks::<2> also drops the indexing. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Fill colour for rectangle, oval and polygon comments (#686) (#793) * M5: fill colour for rectangle, oval and polygon comments (#686) Rectangles, ovals, polygons and clouds could only be given a border colour. They can now be filled, or have their fill removed: - the comment quick bar has a Fill colour control for those tools ("No fill" or a swatch), kept with the other per-tool styles; - Comment Properties > Appearance has a Fill colour row, applied in the same undo step as the other changes and carried over by Make Current Properties Default; - comment_edit takes `fill` (a colour, or "none"). pdfcraft-annot gains `set_fill` (writes or removes /IC and redraws the appearance; lines, ink and locked comments refuse it) and reports `fillable`/`fill` in the comment properties. The engine adds Edit::FillAnnotation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XAJjko29kzKj7ULXoXSnN * M5: update ATTRIBUTION.toml hashes for the edited i18n catalogs (#686) The fill-colour strings changed 13 translation catalogs, so xtask's the_real_manifest_passes failed on ubuntu and windows CI. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: DWAA1660 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * M14: localize displayed shortcut key names (#496) (#884) Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas * M3, #740: column select (Alt/Option-drag, or Edit ▸ Column select) (#765) As in Acrobat, holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle drawn, instead of everything between two points in reading order. It works with the Select tool, the text markup tools and Redact, so a column can be copied, highlighted or redacted. - render: PageText::glyphs_in (glyph centre inside the rectangle), glyph_rects (one box per run, never covering unselected text) and column_text: rows top to bottom, side-by-side pieces separated by a tab, right to left in right-to-left rows, reading order for vertical text. - ui-egui: a selection is a reading-order run or a column rectangle; the rectangle is outlined while dragging and the cursor is a crosshair. ⇧-click clears a column selection (it has no anchor glyph). - Edit ▸ Column select (edit.column_select, quick tool "column-select") does it with a plain drag. It is the way in on Linux desktops whose window manager takes Alt-drag for moving windows (Xfce, Cinnamon, MATE, KDE Plasma 5 by default), where the app never sees the drag. - automation: text_extract takes rect for the same extraction headless. - "Column select" translated in all 15 catalogs (Arabic included); their ATTRIBUTION.toml hashes updated. - parity: new view.column-select entry with platform notes and gaps (no selection across pages, no ⇧-click extension). Windows: winit hands an Alt release to the system menu loop only when the window has a native menu, which PdfCraft's does not, so Alt-drag cannot leave the window in menu mode. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Combine files: a thumbnail grid, card sizes and card actions (refs #611) (#777) Combine files shows each file as a card (the default; Grid/List kept in the settings, --combine-view): the first page it adds, stacked when it adds more, its name shortened in the middle. Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar, selection and history. Locked, unreadable, bad-range and unmeasurable files say why. Thumbnails render off the UI thread, only for cards in view: at most three render threads at once, counted until they exit (no watchdog replacements), a 32 MB texture budget, keys that never show another page, password or file, and one heavy step per frame on the web. Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl +/-/0, Ctrl+wheel or pinch over the grid, kept in the settings, --combine-zoom), keeping the card under the pointer in place. Hovering a card shows its actions over the top of its page: remove that file, and a magnifier that shows it large, flipping through the pages it adds; the same on a right click, and Space by keyboard. The toolbar wraps between groups at narrow widths. RenderPool gains retire(), without_replacements() and threads() so a caller can bound the render threads that exist at once. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Combine files: expand a file into its pages (refs #611) A file that adds more than one page (up to 2000) expands into a card per page it adds, in its range's order, each labelled with its page and the file's name, on a shared tinted band: from its card's hover bar, a right click, or Expand all / Collapse all in the toolbar. A page card's magnifier opens the preview on that page; Up and Down step rows of cards. Expanding is a view: not an undo step, kept across views and zoom, and the combined file is the same either way. Thumbnails are now kept per file and page, looked up on demand (the cost follows the files, not the pages of an expanded file), and a render's document stays open for two seconds so the next page of the same file isn't read again. Adds the Lucide minimize-2 icon (ISC, recorded in ATTRIBUTION). Co-Authored-By: Claude Opus 5.5 --------- Signed-off-by: Hadi Chokr Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: muraa-p <85794781+muraa-p@users.noreply.github.com> Co-authored-by: Big Pickle Co-authored-by: mlkonrad Co-authored-by: Tristan von Kries Co-authored-by: Tristan von Kries Co-authored-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com> Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Co-authored-by: Tevinhead Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com> Co-authored-by: DWAA1660 Co-authored-by: DWAA1660 Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com> Co-authored-by: Justin John Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com> Co-authored-by: Ikki Nishihara Co-authored-by: gyurix Co-authored-by: GyuriX Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: lloyd Co-authored-by: CyberFox-81 Co-authored-by: Aviral Bansal <94526754+A-viral-dev@users.noreply.github.com> Co-authored-by: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com> Co-authored-by: Silver * Combine files: move and remove single pages before combining (closes #611) With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere and removed (a page card's trash, the toolbar, Delete), each one undo step; the files themselves are never changed. A page moved between another file's pages splits its file into parts that share its bytes: the list shows each part as a row with its own page range, and parts that end up side by side join again. A split file still counts, unlocks and combines as one file. organize gains combine_grouped (and Session::combine_grouped): each file is copied once and its pages put in the order shown, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its fields and one copy of its attachments. A page listed twice now gets its own copies of its annotations, and a form field's widget on it joins the same field, so the field keeps one value. The existing combine is unchanged; doc_combine gains an optional groups array (entries with the same number are one file split into parts), so the same combine is reachable headless. Co-Authored-By: Claude Opus 5.5 * Combine: a repeated widget joins its field's /Kids array object too * Organize tests: a repeated widget that is its own field stays one object (#790) --------- Signed-off-by: Hadi Chokr Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: muraa-p <85794781+muraa-p@users.noreply.github.com> Co-authored-by: Big Pickle Co-authored-by: mlkonrad Co-authored-by: Tristan von Kries Co-authored-by: Tristan von Kries Co-authored-by: Krzysztof Cieślik <132496025+Hathor875@users.noreply.github.com> Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Co-authored-by: Tevinhead Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com> Co-authored-by: DWAA1660 Co-authored-by: DWAA1660 Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com> Co-authored-by: Justin John Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Co-authored-by: 才倾梦华V <185041488+CQMHV@users.noreply.github.com> Co-authored-by: Ikki Nishihara Co-authored-by: gyurix Co-authored-by: GyuriX Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: lloyd Co-authored-by: CyberFox-81 Co-authored-by: Aviral Bansal <94526754+A-viral-dev@users.noreply.github.com> Co-authored-by: Epsilon-LLC <64039063+EpsilonLLC@users.noreply.github.com> Co-authored-by: Silver --- ATTRIBUTION.toml | 28 +- crates/automation/src/lib.rs | 55 ++- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 28 ++ crates/engine/src/lib.rs | 44 +++ crates/engine/src/tests.rs | 81 ++++ crates/organize/src/import.rs | 147 +++++++- crates/organize/src/lib.rs | 2 +- crates/organize/src/tests.rs | 127 ++++++- crates/ui-egui/src/combine_grid.rs | 122 ++++-- crates/ui-egui/src/combine_ui.rs | 514 ++++++++++++++++++++++++-- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/tests/combine_grid.rs | 240 ++++++++++-- parity/acrobat-features.toml | 8 +- 27 files changed, 1315 insertions(+), 112 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 1733da08..50086e99 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "92b20307c6f461c4b057e9b2895122f876a2dd48b4101788f2cd47793df70499" +sha256 = "e59d7b71199d61a0e5de541de11509e94a78a5c8f085f12201a6fc096217564a" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c9b4aace4495fb998f3bb3aa233e20ce48f9d7fdf5ca35c0481b438881eddd62" +sha256 = "9c581ca790fc25aa484202c50f9cf6bbe0799572abe90b23fe37f2cba6508825" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7bbf7cf6c65cde241a87c4b0c975cc21ed070c025b00f299deafcc3426e1bce4" +sha256 = "598ead6edbaf5794c300bff2ba767239ad6e8f5c201afe7988952bb5adf1d0e4" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "210f0b8f4bff3f5b9cc22f240536bad68cd1a7de3958f2553dfb54d60555c07e" +sha256 = "24ea1744a8bc0f34acb65bbc64daba47cf3dbb4bb954c14a8b7c752841d6ca46" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6eab259f84f0774a6cd816d04ce7eb16aa44a4004b25fbe2676ce9fef4fb386c" +sha256 = "7b1b5ae014234cc4e8da1496fdade2499ef302d1d882d05c8639eed570ec8619" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f1e14339af91885fdf94d4f800ffee428c355d2368a3d8d56695638692021c44" +sha256 = "757eee5dbc83bde240a744d283241085ea12dadbb59ba9335ed09d077727fe12" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "01a6eff838ad9d34bac63c9c9228ac03c58826ea08e8c63cd26724da8ca00fd5" +sha256 = "27e20bee9b536af7b883a13e3aecb58a3069c1aa9691503000587a61044fa70f" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2daadafd5af3156e6dd269aed320525100a0a305de756c92b14e6078c14efb82" +sha256 = "5c68ea0936961fe24a9ac905c6c9d7e5fe5a8f7203b952865365d421fd1bed79" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27e1f490a6e57e0b7e923d62451e934abcafc66944015d13b009e76a3c598863" +sha256 = "3037387258191e8c7840817675ef871454cf6bd11143842a0c7aeeeef7772c6a" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e913893a535c009df6de3909c1a2c7322293ccdc42ff97b962762d8d8d702a3d" +sha256 = "cb06f042ebbac9bd75dcde134799151be8345e9554a9a930c586a14b8d0650ef" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2109aafdefadde26f2a9da3842c08f1bf58cd920f932cb0d15e40b55f7f86b1b" +sha256 = "c6761eda84aaec2dcd95fd5b4feb549c778567838d894492621b1a43e55251fa" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "503ab86bef47616ead8f03db29bc3f629d31eaad7c4f708e2d5c1f4cda190ee8" +sha256 = "b37f1cae5cd20a2fa30bf237faa3f05700e6d5d1618f03c1ad7328a6deb7086f" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3576db171a132c4494d0bf37531cf0b817f527528e42e8323cad42bc516a9e90" +sha256 = "774def02e9d3803631f6da90b774133851ff5d73c2cb78f86809afbbea650e7e" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "32a866873b2ad4575dc2c25acba14b71d2065110239839948e6577c4a7fb3fb1" +sha256 = "cc448df6f85f1db3c9672e12e97718b98898d1662c87301ee0185b4c11563be0" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6ed206a6..bd9feedd 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -29,7 +29,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use pdfcraft_engine::{DocId, Document, Edit, Session, commands}; +use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands}; use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes}; use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; use serde_json::{Value, json}; @@ -1294,15 +1294,58 @@ impl Automation { } Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), }; - let mut sources = Vec::new(); - for (p, range) in paths.into_iter().zip(ranges) { + // Entries sharing a group number are one file split into parts placed apart. + let groups: Option>> = match a.get("groups") { + None | Some(Value::Null) => None, + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { + Some(v.iter().map(Value::as_u64).collect()) + } + Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), + }; + let mut sources: Vec = Vec::new(); + let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); + for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); + let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); + let bytes = match first { + Some((g, first_path, at)) => { + if *first_path != path { + return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); + } + sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? + } + None => { + if let Some(g) = group { + firsts.push((g, path.clone(), sources.len())); + } + Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?) + } + }; let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - sources.push((name, Arc::new(bytes), range)); + sources.push((name, bytes, range)); } let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); - let bytes = self.session.combine_unlocked(&sources, &passwords).map_err(failed)?; + let bytes = match groups { + None => self.session.combine_unlocked(&sources, &passwords), + Some(groups) => { + // A path without a group is a file of its own: give it a key no group uses. + let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); + let mut keys = Vec::with_capacity(groups.len()); + for g in &groups { + keys.push(match g { + Some(g) => *g, + None => { + let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; + next = own.checked_add(1); + own + } + }); + } + self.session.combine_grouped(&sources, &keys, &passwords) + } + } + .map_err(failed)?; self.deliver(a, "Combined", bytes) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0935582b..280af275 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -197,13 +197,14 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back.") + t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.") .cmd("page.combine") .with(schema( json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, "pages": { "type": "array", "items": { "type": ["string", "null"] } }, "passwords": { "type": "array", "items": { "type": ["string", "null"] } }, + "groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } }, "out": save_out, "open": open, }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1790dc70..e4abe48a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -181,6 +181,34 @@ fn combine_extract_and_split() { assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["9", null] })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": ["1"] })), Err(ToolError::InvalidArgs(_)))); + // A file split around another, as the Combine files grid makes it: copied once, one bookmark. + let split = ok( + &mut a, + "doc_combine", + json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "groups": [4, null, 4], "open": true }), + ); + let split_doc = split["document"]["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, split_doc), ["Page 1", "Page 1", "Page 2", "Page 3", "Page 2"]); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": split_doc }))["bookmarks"].clone(); + let marks: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap().to_string(), b["page"].as_u64().unwrap())).collect(); + assert_eq!(marks, [("a".to_string(), 1), ("b".to_string(), 2)]); + // Without groups the same paths are separate files, each with its own bookmark. + let apart = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf", "a.pdf"], "pages": ["1", null, "3, 2"], "open": true })); + let apart = ok(&mut a, "bookmark_list", json!({ "doc": apart["document"]["doc"].as_u64().unwrap() }))["bookmarks"].clone(); + assert_eq!(apart.as_array().unwrap().len(), 3); + // A group names one file; groups must be in step with paths and whole numbers. + let not_same = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [1, 1] })); + assert!(matches!(not_same, Err(ToolError::InvalidArgs(e)) if e.contains("group 1"))); + for groups in [json!([1]), json!([1, -1]), json!([1, 1.5]), json!(["1", null]), json!(7)] { + let bad = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": groups })); + assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{groups}"); + } + // The largest group number still leaves a key for a file of its own, or says why not. + let max = ok(&mut a, "doc_combine", json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX - 1, null], "open": true })); + assert_eq!(max["document"]["pages"], 5); + let full = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "groups": [u64::MAX, null] })); + assert!(matches!(full, Err(ToolError::InvalidArgs(_)))); + let ex = ok(&mut a, "page_extract", json!({ "doc": doc, "pages": [2, 4] })); let ex_doc = ex["document"]["doc"].as_u64().unwrap(); assert_eq!(page_text(&mut a, ex_doc), ["Page 2", "Page 1"]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cb1494e1..24169f94 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2799,6 +2799,50 @@ impl Session { self.write_new(&out) } + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + // Each file opened once: `file[i]` is source i's place in `docs`. + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped(&runs)?; + self.write_new(&out) + } + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { let src = self.cos(id)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 528f1b33..ad68e816 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -465,6 +465,87 @@ fn mixed_files_convert_and_combine_in_order() { assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]); } +#[test] +fn a_file_split_around_another_combines_in_order_with_one_bookmark() { + let mut s = Session::new(); + let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); + // a's pages 1–2, then b, then a's page 3: one file (group 7) split around another. + let sources: Vec = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; + let combined = s.combine_grouped(&sources, &[7, 9, 7], &[]).unwrap(); + let id = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→3"], "one bookmark for a, at its first page"); + // Without groups each source is a file of its own, as combine_ranges. + let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); + let sources: Vec = vec![("a".into(), a.clone(), Some("1".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; + let combined = s.combine_grouped(&sources, &[], &[]).unwrap(); + let id = s.open_new("Combined 2.pdf", combined).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→2", "a→4"]); + // A bad range is refused with the file's name. + let bad: Vec = vec![("a".into(), Arc::new(fixture(1)), Some("9".into()))]; + assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:"))); +} + +#[test] +fn a_page_with_a_form_field_shown_twice_keeps_one_field() { + // Regression (found by review): the repeated page copied the whole field again, so the + // result had two fields named "shared", filled in separately. + use pdfcraft_cos::{Dict, Document, Object, PdfString}; + let mut doc = Document::open(Arc::new(fixture(2))).unwrap(); + let pages: Vec<_> = pdfcraft_organize::pages(&doc).unwrap().into_iter().map(|p| p.obj).collect(); + let field = doc.add(Object::Null); + let widgets: Vec<_> = pages + .iter() + .map(|p| { + let mut w = Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(100), Object::Int(20)])); + w.set(b"Parent".to_vec(), Object::Ref(field)); + w.set(b"P".to_vec(), Object::Ref(*p)); + doc.add(w) + }) + .collect(); + let mut f = Dict::new(); + f.set(b"FT".to_vec(), Object::name("Tx")); + f.set(b"T".to_vec(), Object::String(PdfString::text("shared"))); + f.set(b"V".to_vec(), Object::String(PdfString::text("Ada"))); + f.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect())); + doc.set(field, Object::Dict(f)); + for (p, w) in pages.iter().zip(&widgets) { + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(*w)]))).unwrap(); + } + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)])); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + // Pages 1, 2, then page 1 again: one file split around nothing but itself. + let s = Session::new(); + let sources: Vec = vec![("form".into(), bytes.clone(), Some("1-2".into())), ("form".into(), bytes, Some("1".into()))]; + let out = s.combine_grouped(&sources, &[1, 1], &[]).unwrap(); + let mut out = Document::open(out).unwrap(); + pdfcraft_forms::set_value(&mut out, "shared", &pdfcraft_forms::FieldValue::Text("Grace".into())).unwrap(); + let fields = pdfcraft_forms::fields(&out); + assert!( + !fields.is_empty() && fields.iter().all(|f| f.name == "shared" && f.value == ["Grace"]), + "one field, one value: {:?}", + fields.iter().map(|f| (&f.name, &f.value)).collect::>() + ); + let catalog = out.get(out.root().unwrap()).as_dict().cloned().unwrap(); + let form = out.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let listed = form.get(b"Fields").and_then(|f| f.as_array()).cloned().unwrap(); + assert_eq!(listed.len(), 1, "the field isn't copied again for the repeated page"); + // Its widgets: one on each of the three pages, each belonging to its own page. + let out_pages: Vec<_> = pdfcraft_organize::pages(&out).unwrap().into_iter().map(|p| p.obj).collect(); + let kids = out.resolve(&listed[0]).as_dict().and_then(|f| f.get(b"Kids").and_then(|k| k.as_array()).cloned()).unwrap(); + assert_eq!(kids.len(), 3); + let mut on: Vec<_> = kids.iter().map(|k| out.resolve(k).as_dict().and_then(|w| w.reference(b"P")).unwrap()).collect(); + on.sort(); + let mut expected = out_pages.clone(); + expected.sort(); + assert_eq!(on, expected, "a widget per page, on its own page"); +} + #[test] fn files_that_cannot_be_converted_are_refused_clearly() { let s = Session::new(); diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 4d6c5957..b3342c9b 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -237,16 +237,19 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], let mut new_pages = Vec::new(); let mut done: HashMap = HashMap::new(); for (page, new, inherited) in targets { - // A page listed twice: make a second, independent page object sharing resources. Its - // annotations (with their popups and form fields) are copied again, so each page owns its - // copies and their `/P` names it, as Duplicate Pages does (#818). - let new = if done.contains_key(&page) { - let copies: std::collections::HashSet = copier.annots.iter().chain(&copier.fields).copied().collect(); - copier.map.retain(|_, &mut d| !copies.contains(&d)); - dst.add(Object::Null) - } else { - new - }; + // A page listed twice: make a second, independent page object sharing resources, and + // with copies of its own annotations (an annotation belongs to one page: its `/P`, its + // popup, a widget's place). Destinations still map to the pages copied. + let repeat = done.contains_key(&page); + let new = if repeat { dst.add(Object::Null) } else { new }; + let mut own = repeat.then(|| Copier { + src, + map: HashMap::new(), + pages: copier.pages.clone(), + annots: Vec::new(), + fields: Vec::new(), + ocgs: Vec::new(), + }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let mut d = Dict::new(); for (k, v) in src_dict.iter() { @@ -257,13 +260,48 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], let list = src.resolve(v).as_array().cloned().unwrap_or_default(); let mut out = Vec::new(); for a in list { + // On a repeated page a form field's widget becomes one more widget of the + // same field (copied already with the page's first copy): one field, one + // value, shown on both pages. Copying the field again would make two fields + // of one name, filled in separately. + let resolved = src.resolve(&a); + let field = resolved.as_dict().filter(|d| d.name(b"Subtype") == Some(b"Widget")).and_then(|d| d.reference(b"Parent")); + if repeat + && let Some(parent) = field + && let Some(copied) = copier.map.get(&parent).copied() + { + let r = dst.add(Object::Null); + let c = copier.copy_value(dst, &resolved, Some(r)); + dst.set(r, c); + // `/Kids` may be an array object of its own: add to it there, so the + // field keeps its other widgets. + match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(r)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(copied, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(r)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + annot_pages.insert(r, new); + out.push(Object::Ref(r)); + continue; + } + // A widget that is its own field (no parent to hang a second widget on) stays + // the one object, shown on both pages, so the field stays one. + let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")); + let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) }; // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. let r = match a { - Object::Ref(r) => copier.copy_ref(dst, r), + Object::Ref(r) => annots.copy_ref(dst, r), Object::Dict(_) => { let r = dst.add(Object::Null); - let c = copier.copy_value(dst, &a, Some(r)); + let c = annots.copy_value(dst, &a, Some(r)); dst.set(r, c); r } @@ -288,6 +326,12 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], d.set(b"Type".to_vec(), Object::name("Page")); d.set(b"Parent".to_vec(), Object::Ref(root)); dst.set(new, Object::Dict(d)); + // The repeat's own annotations are fixed up, its fields and layers registered, with the rest. + if let Some(own) = own { + copier.annots.extend(own.annots); + copier.fields.extend(own.fields); + copier.ocgs.extend(own.ocgs); + } done.insert(page, new); new_pages.push(new); } @@ -537,6 +581,85 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu Ok(out) } +/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same +/// group are one file split around, and give the same document), its title, the document, and +/// its pages (0-based, in that order; `None` for all of them). +pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); + +/// Combine Files where a file's pages may be split into several runs with other files' pages +/// between them. Each file (group) is copied once, all its pages together, then the pages are put +/// in the runs' order: its links between its own pages, its form fields and its attachments stay +/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its +/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). +pub fn combine_grouped(runs: &[Run<'_>]) -> Result { + // Each file's pages, in the order its runs list them, and each run's place in that list. + struct File<'a> { + title: &'a str, + src: &'a Document, + pages: Vec, + } + let mut files: Vec<(usize, File<'_>)> = Vec::new(); + let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); + for (group, title, src, chosen) in runs { + let at = match files.iter().position(|(g, _)| g == group) { + Some(at) => at, + None => { + files.push((*group, File { title, src, pages: Vec::new() })); + files.len() - 1 + } + }; + let Some((_, file)) = files.get_mut(at) else { continue }; + let n = crate::page_count(file.src)?; + let pages: Vec = match chosen { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + slices.push((at, file.pages.len(), pages.len())); + file.pages.extend(pages); + } + let mut out = Document::new_empty(); + let mut marks = Vec::new(); + let mut attachments = Vec::new(); + let mut copies: Vec> = Vec::with_capacity(files.len()); + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; + for (_, file) in &files { + let at = crate::page_count(&out)?; + let imported = import_pages_mapped(&mut out, file.src, &file.pages, at)?; + copies.push(imported.pages.clone()); + if let Some(first) = imported.pages.first() { + marks.push((file.title.to_string(), *first, file.src, imported.page_map)); + } + collect_attachments(&mut out, file.src, &mut attachments); + let declared = crate::pdfx::PrintStandard::of(file.src); + match &standard { + None => standard = Some((declared, file.src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + // The pages in the runs' order. A file's first run comes before its others, and its pages + // were copied in run order, so its bookmark (its first copied page) is its first page shown. + let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + for (file, start, len) in slices { + let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + order.extend(run.iter().map(|r| (*r, Dict::new()))); + } + rebuild(&mut out, &order)?; + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, src, &declared, objects)?; + } + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(out) +} + /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) { let Some(tree) = src diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 6d40fed1..ad7d98f5 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -24,7 +24,7 @@ pub mod view; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; -pub use import::{SplitBy, combine, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges}; +pub use import::{Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges}; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ Bookmark, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index ab93f935..fe5e1aa1 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -443,6 +443,11 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } +/// A widget annotation that is its own form field (no `/Parent`). +fn own_field(doc: &Document, r: ObjRef) -> bool { + doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")) +} + #[test] fn repeated_page_gets_independent_annotations() { // A1 has a link, A2 a link and a form field, A3 a note with a popup; each is listed twice. @@ -456,11 +461,15 @@ fn repeated_page_gets_independent_annotations() { assert!(!refs[i].is_empty()); assert_eq!(refs[i].len(), refs[i + 1].len()); for (a, b) in refs[i].iter().zip(&refs[i + 1]) { - assert_ne!(a, b, "each copy of the page has its own annotation objects"); + // A widget that is its own field stays one object on both pages, so the field stays + // one (#790); every other annotation is copied. + if !own_field(&out, *a) { + assert_ne!(a, b, "each copy of the page has its own annotation objects"); + } } } for (i, list) in refs.iter().enumerate() { - for a in list { + for a in list.iter().filter(|a| !own_field(&out, **a)) { assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); } } @@ -751,6 +760,96 @@ fn layers_are_registered_with_their_default_state() { assert!(!catalog(&out).contains(b"OCProperties")); } +/// The top-level bookmarks' titles, in order. +fn top_titles(doc: &Document) -> Vec { + let Some(outlines) = catalog(doc).reference(b"Outlines") else { return Vec::new() }; + let mut item = doc.get(outlines).as_dict().and_then(|o| o.reference(b"First")); + let mut titles = Vec::new(); + while let Some(r) = item { + let d = doc.get(r).as_dict().cloned().unwrap(); + titles.push(d.get(b"Title").and_then(|t| t.as_string()).map(|s| s.to_text()).unwrap()); + item = d.reference(b"Next"); + } + titles +} + +#[test] +fn a_file_split_around_another_is_copied_once_with_one_bookmark() { + // C's first page, then all of A, then C's second page. + let (a, c) = (doc_a(), doc_c()); + let out = full_roundtrip(&crate::combine_grouped(&[(0, "C", &c, Some(&[0])), (1, "A", &a, None), (0, "C", &c, Some(&[1]))]).unwrap()); + assert_eq!(labels(&out), ["C1", "A1", "A2", "A3", "C2"]); + assert_eq!(top_titles(&out), ["C", "A"], "one bookmark per file, not per run"); + let ps = pages(&out).unwrap(); + let outlines = out.get(catalog(&out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap(); + let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(file.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[0].obj), "C's bookmark: its first page shown"); + // Its own bookmarks follow its pages wherever they went: the section is on C2, now last. + let chapter = out.get(file.reference(b"First").unwrap()).as_dict().cloned().unwrap(); + let section = out.get(chapter.reference(b"First").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(section.get(b"Dest").and_then(|d| d.as_array()).unwrap()[0].as_ref(), Some(ps[4].obj)); + // The link from C1 to C2 still works across A's pages between them. + let link = &annots(&out, 0)[0]; + assert_eq!(link.get(b"Dest").and_then(|d| d.as_array()).expect("kept").first().and_then(|o| o.as_ref()), Some(ps[4].obj)); + // Its attachment once, not once per run. + let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let keys: Vec = tree.get(b"Names").and_then(|n| n.as_array()).unwrap().chunks(2).map(|p| p[0].as_string().unwrap().to_text()).collect(); + assert_eq!(keys, ["notes.txt"]); +} + +#[test] +fn a_page_shown_twice_gets_its_own_annotations() { + // One page with an indirect comment (and its popup), and a second page. + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [6 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".into(), // 4 + body("D1"), // 5 + "<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (note) /P 3 0 R /Popup 8 0 R >>".into(), // 6 + body("D2"), // 7 + "<< /Type /Annot /Subtype /Popup /Rect [10 10 50 50] /Parent 6 0 R >>".into(), // 8 + ]; + let d = open(build(&b, "/Root 1 0 R")); + // Page 1 twice, from one file split around its page 2 (and the same through combine_selected). + for out in [ + crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (0, "D", &d, Some(&[0]))]).unwrap(), + combine_selected(&[("D", &d, Some(&[0, 1, 0]))]).unwrap(), + ] { + let out = full_roundtrip(&out); + assert_eq!(labels(&out), ["D1", "D2", "D1"]); + let ps = pages(&out).unwrap(); + let annot_of = |i: usize| page_dict(&out, i).get(b"Annots").and_then(|a| a.as_array()).and_then(|a| a[0].as_ref()).unwrap(); + let (first, again) = (annot_of(0), annot_of(2)); + assert_ne!(first, again, "two pages, two comments"); + for (annot, page) in [(first, ps[0].obj), (again, ps[2].obj)] { + let a = out.get(annot).as_dict().cloned().unwrap(); + assert_eq!(a.reference(b"P"), Some(page), "each comment belongs to its own page"); + let popup = out.get(a.reference(b"Popup").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(popup.reference(b"Parent"), Some(annot), "and its popup to it"); + } + } +} + +#[test] +fn grouped_runs_keep_their_order_repeats_and_separate_files() { + let (a, b) = (doc_a(), doc_b()); + // B's page 2, A's page 1, B's page 1, A's page 1 again (a repeat is a page of its own). + let out = full_roundtrip( + &crate::combine_grouped(&[(0, "B", &b, Some(&[1])), (1, "A", &a, Some(&[0])), (0, "B", &b, Some(&[0])), (1, "A", &a, Some(&[0]))]).unwrap(), + ); + assert_eq!(labels(&out), ["B2", "A1", "B1", "A1"]); + assert_eq!(top_titles(&out), ["B", "A"]); + let ps = pages(&out).unwrap(); + assert_ne!(ps[1].obj, ps[3].obj, "a page shown twice is two pages"); + // The same file added twice on purpose (two groups) keeps two bookmarks, as combine does. + let twice = full_roundtrip(&crate::combine_grouped(&[(0, "A", &a, None), (1, "A again", &a, None)]).unwrap()); + assert_eq!(top_titles(&twice), ["A", "A again"]); + // A page that isn't there is refused, as combine_selected does. + assert!(matches!(crate::combine_grouped(&[(0, "A", &a, Some(&[9]))]), Err(crate::OrganizeError::NoSuchPage(9)))); +} + #[test] fn combine_nests_source_bookmarks_and_keeps_attachments() { let out = full_roundtrip(&combine(&[("C", &doc_c()), ("C again", &doc_c())]).unwrap()); @@ -1397,3 +1496,27 @@ fn extract_keeps_images_type3_glyphs_may_draw() { let names = xobject_names(&part, 0); assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources"); } + +/// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the +/// second widget joins that array, and the field keeps every widget it had. +#[test] +fn a_repeated_widget_joins_an_indirect_kids_array() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Annots [8 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 90 30] /P 3 0 R >>".into(), // 5 + "<< /FT /Tx /T (name) /V (Ada) /Kids 7 0 R >>".into(), // 6 + "[5 0 R 8 0 R]".into(), // 7 + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 40 90 60] /P 4 0 R >>".into(), // 8 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let out = full_roundtrip(&extract_pages(&src, &[0, 0, 1]).unwrap()); + let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap()); + let fields = form.as_dict().unwrap().get(b"Fields").and_then(Object::as_array).cloned().unwrap(); + assert_eq!(fields.len(), 1, "one field"); + let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); + let kids = out.resolve(field.get(b"Kids").unwrap()).as_array().cloned().unwrap(); + assert_eq!(kids.len(), 3, "both original widgets and the repeat's own: {kids:?}"); +} diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index c1f368de..80facbc8 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -99,6 +99,8 @@ pub enum ThumbState { struct GridDrag { file: u64, revision: u64, + /// A page card's page (entry id, place among its pages): that page moves, not its file. + page: Option<(u64, usize)>, } /// Everything a thumbnail depends on: the file's bytes, how it was read (its password), the page @@ -501,8 +503,8 @@ pub(crate) struct PageList { } /// The pages an expanded file shows, worked out again only when its range or page count -/// changes; `None` when it can't be expanded (locked, unreadable, a bad range, a single page, or -/// more than [`MAX_EXPAND`]). +/// changes; `None` when it can't be shown as its pages (locked, unreadable, a bad range, no page, +/// or more than [`MAX_EXPAND`]). pub(crate) fn pages_shown(cache: &mut HashMap, f: &CombineFile) -> Option { if f.lock.is_some() || f.problem.is_some() { return None; @@ -518,7 +520,8 @@ pub(crate) fn pages_shown(cache: &mut HashMap, f: &CombineFile) - list } }; - (list.order.len() > 1 && list.order.len() <= MAX_EXPAND).then_some(list) + // (One page shows as a page card too: a file split into parts may leave one page in a part.) + (!list.order.is_empty() && list.order.len() <= MAX_EXPAND).then_some(list) } /// The grid's places, in order: each file's card, or its pages when it is expanded. @@ -564,11 +567,12 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: let released = drag.is_some() && ctx.input(|i| i.pointer.any_released()); let n = app.combine_draft.len(); // The same file twice is allowed (e.g. a cover sheet), but probably a mistake. - let mut copies: HashMap<(&str, usize), usize> = HashMap::new(); + // (A file's parts are the one file, not copies: counted by file.) + let mut copies: HashMap<(&str, usize), std::collections::BTreeSet> = HashMap::new(); for f in &app.combine_draft { - *copies.entry((f.name.as_str(), f.bytes.len())).or_default() += 1; + copies.entry((f.name.as_str(), f.bytes.len())).or_default().insert(f.group); } - let twice: Vec = app.combine_draft.iter().map(|f| copies.get(&(f.name.as_str(), f.bytes.len())).is_some_and(|n| *n > 1)).collect(); + let twice: Vec = app.combine_draft.iter().map(|f| copies.get(&(f.name.as_str(), f.bytes.len())).is_some_and(|g| g.len() > 1)).collect(); let places = layout(app); // Each file's first place (for the keys and to scroll to it). let mut first_place = vec![0usize; n]; @@ -578,6 +582,20 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: } } app.combine_tab.grid_places = places.iter().map(|c| c.file).collect(); + // Pages counted over the list (an entry that can't be split counting as one, see + // `moved_pages`): where each place's pages start, for a page dropped before it. + let counts: Vec = app + .combine_draft + .iter_mut() + .map(|f| if f.lock.is_none() && f.problem.is_none() { f.selection().ok().filter(|k| *k > 0).unwrap_or(1) } else { 1 }) + .collect(); + let mut page_at = Vec::with_capacity(places.len()); + let mut total_pages = 0usize; + for c in &places { + page_at.push(total_pages); + total_pages = total_pages.saturating_add(if c.page.is_some() { 1 } else { counts.get(c.file).copied().unwrap_or(1) }); + } + let picked_pages = app.combine_tab.selected_pages.clone(); let reveal = app.combine_tab.reveal.take(); let fit = paper_box(app.combine_zoom); let cell = fit + AROUND; @@ -652,7 +670,8 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: page: place.page, rect: c, check: checks.get(place.file).cloned().unwrap_or(Ok(f.pages)), - selected: selected.get(place.file).copied().unwrap_or(false), + selected: selected.get(place.file).copied().unwrap_or(false) + || place.page.is_some_and(|(nth, ..)| picked_pages.contains(&(f.id, nth))), twice: twice.get(place.file).copied().unwrap_or(false), revision, ppp, @@ -667,10 +686,32 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: } } } - // While a card is dragged: the file gap it would go to, drawn as a bar, and how many go. + // While a page card is dragged: the page gap it would go to (any place, between any two + // pages), drawn at the nearest card's edge, and how many pages go. if let Some(d) = drag + && let Some(page) = d.page && let Some(p) = pointer { + let at = crate::canvas::drop_gap(&cells, p); + let nearest = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p))); + if let Some((_, r)) = nearest { + let x = if p.x < r.center().x { r.left() + 2.0 } else { r.right() - 2.0 }; + ui.painter().line_segment([pos2(x, r.top() + 10.0), pos2(x, r.bottom() - 30.0)], Stroke::new(3.0, t.accent)); + } + let moving = if picked_pages.contains(&page) { picked_pages.len() } else { 1 }; + let label = if moving == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &moving.to_string())]) }; + ui.painter().text(p + vec2(14.0, 14.0), Align2::LEFT_TOP, label, theme::medium(12.0), t.accent_text); + if released + && viewport.contains(p) + && let Some(at) = at + { + let gap = page_at.get(at).copied().unwrap_or(total_pages); + action = Some(RowAction::MovePages { page, revision: d.revision, gap }); + } + } else if let Some(d) = drag + && let Some(p) = pointer + { + // A file's card: the file gap it would go to, drawn as a bar, and how many files go. let gap = crate::canvas::drop_gap(&cells, p).map(|at| file_gap(&places, at, n)); // Drawn at the end of the file before the gap or the start of the one after (an // expanded file's pages are never split), whichever is nearer the pointer. @@ -864,7 +905,7 @@ impl Card { let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; - let look = thumbs.look((f.id, p), key); + let look = thumbs.look((f.group, p), key); let mut want = false; match look { Look::Ready(tex) => { @@ -880,7 +921,7 @@ impl Card { } if want && scale.is_finite() && scale > 0.0 { thumbs.wants.push(Want { - slot: (f.id, p), + slot: (f.group, p), key, bytes: f.bytes.clone(), password: f.password().map(Arc::from), @@ -950,14 +991,15 @@ impl Card { Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), None => RowAction::Preview(f.id), }; - // The card's own actions, in this order: expand (or, on a page, collapse), remove, show - // large. Page cards can't be removed one by one (yet): no trash on them. + // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, + // or the page), show large. let mut buttons: Vec<(&str, String, RowAction)> = Vec::new(); if expandable { buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); } - if self.page.is_some() { + if let Some((nth, ..)) = self.page { buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); } else { buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); } @@ -1025,10 +1067,14 @@ impl Card { } }); if resp.drag_started() { - egui::DragAndDrop::set_payload(ui.ctx(), GridDrag { file: f.id, revision: self.revision }); + egui::DragAndDrop::set_payload(ui.ctx(), GridDrag { file: f.id, revision: self.revision, page: self.page.map(|(nth, ..)| (f.id, nth)) }); } if resp.clicked() { - action = Some(RowAction::Click(self.i, resp.ctx.input(|i| i.modifiers))); + let mods = resp.ctx.input(|i| i.modifiers); + action = Some(match self.page { + Some((nth, ..)) => RowAction::ClickPage(f.id, nth, mods), + None => RowAction::Click(self.i, mods), + }); } action } @@ -1130,7 +1176,7 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { } Look::Pending(stand_in) => { // A smaller render of this page stands in: the last size shown, or its card's. - let card = thumbs.entries.get(&(f.id, page)).filter(|e| e.key.auth == f.auth).and_then(|e| e.tex.as_ref()); + let card = thumbs.entries.get(&(f.group, page)).filter(|e| e.key.auth == f.auth).and_then(|e| e.tex.as_ref()); if let Some(tex) = stand_in.or(card.map(TextureHandle::id)) { painter.image(tex, paper, uv, Color32::WHITE); } @@ -1196,18 +1242,24 @@ impl PdfCraftApp { pub(crate) fn combine_thumbs_frame(&mut self, ctx: &egui::Context) { // What each thumbnail must match now (cards out of view included): each file's first page, // and an expanded file's pages (looked up in its sorted list, never listed one by one). + // (By file: the parts a file is split into share its thumbnails.) struct Shown { source: usize, auth: u64, - first: usize, - sorted: Option>, + pages: Vec, } let mut shown: HashMap = HashMap::with_capacity(self.combine_draft.len()); let tab = &mut self.combine_tab; for f in self.combine_draft.iter_mut() { let Ok(first) = f.first_page() else { continue }; let sorted = tab.expanded.contains(&f.id).then(|| pages_shown(&mut tab.page_lists, f)).flatten().map(|l| l.sorted); - shown.insert(f.id, Shown { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, first, sorted }); + let entry = shown.entry(f.group).or_insert_with(|| Shown { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, pages: Vec::new() }); + entry.pages.push(first); + entry.pages.extend(sorted.iter().flat_map(|s| s.iter().copied())); + } + for s in shown.values_mut() { + s.pages.sort_unstable(); + s.pages.dedup(); } // The page shown large, while it shows: leaving the tab closes it. if !self.combine_showing() { @@ -1224,16 +1276,32 @@ impl PdfCraftApp { } let (id, page) = slot; let s = shown.get(&id)?; - (page == s.first || s.sorted.as_ref().is_some_and(|p| p.binary_search(&page).is_ok())).then_some(Key { - source: s.source, - auth: s.auth, - page, - px: [0, 0], - }) + s.pages.binary_search(&page).is_ok().then_some(Key { source: s.source, auth: s.auth, page, px: [0, 0] }) }; self.combine_thumbs.finish(ctx, ¤t); } + /// The single pages selected: each one's entry (by place in the list) and page (0-based, in + /// the file), in list order (tests and the control channel). + pub fn combine_selected_pages(&self) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + for (i, f) in self.combine_draft.iter().enumerate() { + let pages = f.pages_taken().unwrap_or_default(); + for (nth, page) in pages.iter().enumerate() { + if self.combine_tab.selected_pages.contains(&(f.id, nth)) { + out.push((i, *page)); + } + } + } + out + } + + /// Each entry's name and the pages it takes (0-based, in order): a file split into parts is + /// several entries (tests and the control channel). + pub fn combine_parts_shown(&self) -> Vec<(String, Vec)> { + self.combine_draft.iter().map(|f| (f.name.clone(), f.pages_taken().unwrap_or_default())).collect() + } + /// The files shown as their pages in the grid, by place in the list (tests and the control /// channel). pub fn combine_expanded(&self) -> Vec { @@ -1245,12 +1313,12 @@ impl PdfCraftApp { pub fn combine_page_thumbnails(&self, file: usize) -> Vec<(usize, ThumbState)> { let Some(f) = self.combine_draft.get(file).filter(|f| self.combine_tab.expanded.contains(&f.id)) else { return Vec::new() }; let pages = f.pages_taken().unwrap_or_default(); - pages.into_iter().map(|p| (p, self.combine_thumbs.state((f.id, p)))).collect() + pages.into_iter().map(|p| (p, self.combine_thumbs.state((f.group, p)))).collect() } /// Each listed file's grid thumbnail, in list order (tests and the control channel). pub fn combine_thumbnails(&self) -> Vec { - self.combine_draft.iter().map(|f| f.first_taken().map_or(ThumbState::None, |p| self.combine_thumbs.state((f.id, p)))).collect() + self.combine_draft.iter().map(|f| f.first_taken().map_or(ThumbState::None, |p| self.combine_thumbs.state((f.group, p)))).collect() } /// Thumbnail renders under way (tests: never more than a few). diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index de3086e2..faefb68f 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -208,6 +208,11 @@ pub struct CombineTab { /// That card's file and its place among the file's pages (0 for the file's own card): Space /// on a page card shows that page. pub(crate) card_focus_at: Option<(u64, usize)>, + /// Single pages selected in the grid (an expanded file's page cards), by entry id and place + /// among the entry's pages; the files' selection is empty while any is. + pub(crate) selected_pages: BTreeSet<(u64, usize)>, + /// The page a Shift-click selects from. + pub(crate) page_anchor: Option<(u64, usize)>, } /// The pages a range takes, and the range and page count they were worked out for. @@ -256,6 +261,7 @@ struct UnlockPrompt { } /// What can be known about a file up front, opened with a password or not. +#[derive(Clone)] struct Assessed { pages: usize, /// Each page's displayed size in points (empty when the inspector couldn't read it). @@ -347,6 +353,9 @@ type RangeCheck = Result<(usize, usize), String>; pub struct CombineFile { /// Stable while the file is in the list (selection, undo). pub id: u64, + /// The file it is (part of): the entries a file is split into (a page moved between another + /// file's pages) share it. Combined as one file, unlocked together, counted once. + pub(crate) group: u64, pub name: String, pub bytes: Arc>, pub pages: usize, @@ -486,6 +495,18 @@ pub(crate) enum RowAction { Expand(u64, bool), /// Every file that adds more than one page, as its pages (`true`), or all as cards again. ExpandAll(bool), + /// A click on a page card: that page (by entry id and place among its pages) selected, + /// Ctrl/⌘ toggling it, Shift selecting up to it. + ClickPage(u64, usize, Modifiers), + /// A page card (and the rest of the selected pages, if it is selected) dropped before page + /// `gap` of the list (see [`moved_pages`]), if the list hasn't changed since the drag began. + MovePages { + page: (u64, usize), + revision: u64, + gap: usize, + }, + /// One page's trash (`Some`), or the selected pages: out of the list. + RemovePages(Option<(u64, usize)>), /// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`. Drop { from: usize, @@ -522,6 +543,28 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let n = app.combine_draft.len(); let ids: Vec = app.combine_draft.iter().map(|f| f.id).collect(); app.combine_tab.selected.retain(|id| ids.contains(id)); + // Pages stay picked only while they show (a grid card of an expanded file), so the trash or + // Delete never takes a page the user can't see. + { + let tab = &mut app.combine_tab; + if app.combine_view == CombineView::Grid { + let shown: BTreeSet<(u64, usize)> = app + .combine_draft + .iter_mut() + .filter(|f| tab.expanded.contains(&f.id)) + .flat_map(|f| { + let id = f.id; + (0..f.selection().unwrap_or(0)).map(move |nth| (id, nth)) + }) + .collect(); + tab.selected_pages.retain(|p| shown.contains(p)); + } else { + tab.selected_pages.clear(); + } + if tab.selected_pages.is_empty() { + tab.page_anchor = None; + } + } // A file stays expanded only while it can show as its pages (not locked again by undo, nor // edited down to one page or a bad range): it doesn't come back expanded by itself. { @@ -543,7 +586,8 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let covered = app.combine_tab.unlock.is_some() || app.combine_tab.preview.is_some(); let focus = ui.memory(|m| m.focused()); let space = focus.is_none() || focus == app.combine_tab.card_focus; - let keys = if covered || slider_focused { None } else { keyboard(ui, n, count > 0, columns, space) }; + let any_picked = count > 0 || !app.combine_tab.selected_pages.is_empty(); + let keys = if covered || slider_focused { None } else { keyboard(ui, n, any_picked, columns, space) }; let mut events = TableEvents { action: keys, ..Default::default() }; // ⌘+ / ⌘− / ⌘0 size the grid's cards (instead of the whole window's text). if grid && n > 0 && !covered { @@ -605,13 +649,16 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let can_up = selected.iter().skip_while(|s| **s).any(|s| *s); let can_down = selected.iter().rev().skip_while(|s| **s).any(|s| *s); let why = tl!("Select a file first"); - let remove = match count { - 1 => { + let pages_picked = app.combine_tab.selected_pages.len(); + let remove = match (pages_picked, count) { + (1, _) => tl!("Remove page").to_string(), + (k, _) if k > 1 => crate::i18n::fmt(tl!("Remove {n} pages"), &[("n", &k.to_string())]), + (_, 1) => { let name = app.combine_draft.iter().find(|f| app.combine_tab.selected.contains(&f.id)).map_or("", |f| f.name.as_str()); crate::i18n::fmt(tl!("Remove {name}"), &[("name", name)]) } - 0 => tl!("Remove").to_string(), - k => crate::i18n::fmt(tl!("Remove {n} files"), &[("n", &k.to_string())]), + (_, 0) => tl!("Remove").to_string(), + (_, k) => crate::i18n::fmt(tl!("Remove {n} files"), &[("n", &k.to_string())]), }; if tool(ui, can_up, "chevron-up", tl!("Move up"), why).clicked() { events.action = Some(RowAction::MoveUp); @@ -619,7 +666,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if tool(ui, can_down, "chevron-down", tl!("Move down"), why).clicked() { events.action = Some(RowAction::MoveDown); } - if tool(ui, count > 0, "trash-2", &remove, why).clicked() { + if tool(ui, count > 0 || pages_picked > 0, "trash-2", &remove, why).clicked() { events.action = Some(RowAction::Remove); } let locked = app.combine_draft.iter().any(|f| f.lock.is_some() && app.combine_tab.selected.contains(&f.id)); @@ -702,11 +749,22 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if n > 0 { ui.horizontal(|ui| { let pages: usize = checks.iter().filter_map(|c| c.as_ref().ok()).sum(); - let size: usize = app.combine_draft.iter().map(|f| f.bytes.len()).sum(); + // Each file's size once, however many parts it is in. + let mut counted = BTreeSet::new(); + let size: usize = app.combine_draft.iter().filter(|f| counted.insert(f.group)).map(|f| f.bytes.len()).sum(); + // A file split into parts counts once. + let n = app.combine_draft.iter().map(|f| f.group).collect::>().len(); let files = if n == 1 { tl!("1 file").to_string() } else { crate::i18n::fmt(tl!("{n} files"), &[("n", &n.to_string())]) }; let pages = if pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.to_string())]) }; ui.label(egui::RichText::new(format!("{files} · {pages} · {}", crate::panels::human_size(size))).color(t.text_muted)); - let bad = app.combine_draft.iter().zip(&checks).filter(|(f, c)| f.problem.is_some() || c.is_err()).count(); + let bad = app + .combine_draft + .iter() + .zip(&checks) + .filter(|(f, c)| f.problem.is_some() || c.is_err()) + .map(|(f, _)| f.group) + .collect::>() + .len(); if bad > 0 { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let text = if bad == 1 { @@ -849,7 +907,8 @@ fn expandable(f: &mut CombineFile) -> bool { /// Why Combine can't run yet, if so. fn blocker(files: &[CombineFile], checks: &[Result]) -> Option { - if files.len() < 2 { + // (A file split into parts is still one file.) + if files.iter().map(|f| f.group).collect::>().len() < 2 { return Some(tl!("Add at least two files").to_string()); } for (f, check) in files.iter().zip(checks) { @@ -1043,10 +1102,11 @@ fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result], selected: &[bool]) -> TableEvents { let mut events = TableEvents::default(); // The same file twice is allowed (e.g. a cover sheet), but probably a mistake. + // (A file's parts are the one file, not copies.) let twice: Vec = app .combine_draft .iter() - .map(|f| app.combine_draft.iter().filter(|g| g.name == f.name && g.bytes.len() == f.bytes.len()).count() > 1) + .map(|f| app.combine_draft.iter().filter(|g| g.group != f.group && g.name == f.name && g.bytes.len() == f.bytes.len()).count() > 0) .collect(); let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()); // Until the user resizes a column, the widths follow the window. @@ -1444,6 +1504,135 @@ pub(crate) fn moved_order(ids: &[u64], moving: &BTreeSet, gap: usize) -> Op (rest != ids).then_some(rest) } +/// A page range (1-based: "1-3, 6, 9-7") that takes exactly `pages` (0-based, in that order) +/// from a file of `count` pages; empty (every page) when that is all of them in order. +pub(crate) fn range_of(pages: &[usize], count: usize) -> String { + if pages.len() == count && pages.iter().enumerate().all(|(i, p)| i == *p) { + return String::new(); + } + let mut parts: Vec = Vec::new(); + let mut i = 0; + while let Some(&start) = pages.get(i) { + // The longest run going up (or down) one page at a time from here. + let step = |a: usize, b: usize| { + if b == a.wrapping_add(1) { + 1 + } else if a == b.wrapping_add(1) { + -1 + } else { + 0 + } + }; + let dir = pages.get(i + 1).map_or(0, |&next| step(start, next)); + let mut end = i; + while dir != 0 && pages.get(end + 1).is_some_and(|&next| pages.get(end).is_some_and(|&cur| step(cur, next) == dir)) { + end += 1; + } + let last = pages.get(end).copied().unwrap_or(start); + parts.push(if end == i { format!("{}", start.saturating_add(1)) } else { format!("{}-{}", start.saturating_add(1), last.saturating_add(1)) }); + i = end + 1; + } + parts.join(", ") +} + +/// One list entry, for moving and removing single pages: its file (`group`: the parts of one +/// file split around share it) and the pages it takes, in order; `None` when they can't be +/// listed (a locked file, a bad range): such an entry only ever moves whole. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Part { + pub(crate) id: u64, + pub(crate) group: u64, + pub(crate) pages: Option>, +} + +/// One page in the list's order, or an entry that moves whole. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Item { + Page { group: u64, from: u64, nth: usize, page: usize }, + Whole(u64), +} + +/// Every page in the list's order (and the entries that can't be split, whole). +fn items_of(parts: &[Part]) -> Vec { + let mut items = Vec::new(); + for p in parts { + match &p.pages { + Some(pages) => items.extend(pages.iter().enumerate().map(|(nth, &page)| Item::Page { group: p.group, from: p.id, nth, page })), + None => items.push(Item::Whole(p.id)), + } + } + items +} + +/// Entries again from `items`: a file's pages side by side are one entry (its first unused id +/// among them, else a new one from `new_id`); entries that move whole keep theirs. +fn parts_of(items: &[Item], parts: &[Part], new_id: &mut dyn FnMut() -> u64) -> Vec { + let mut out: Vec = Vec::new(); + let mut used: BTreeSet = BTreeSet::new(); + let mut run: Vec = Vec::new(); + let mut close = |run: &mut Vec, out: &mut Vec, used: &mut BTreeSet| { + let Some(Item::Page { group, .. }) = run.first().copied() else { return }; + let id = run.iter().find_map(|i| match i { + Item::Page { from, .. } if !used.contains(from) => Some(*from), + _ => None, + }); + let id = id.unwrap_or_else(&mut *new_id); + used.insert(id); + let pages = run.iter().filter_map(|i| if let Item::Page { page, .. } = i { Some(*page) } else { None }).collect(); + out.push(Part { id, group, pages: Some(pages) }); + run.clear(); + }; + for item in items { + match item { + Item::Whole(id) => { + close(&mut run, &mut out, &mut used); + if let Some(p) = parts.iter().find(|p| p.id == *id) { + used.insert(*id); + out.push(p.clone()); + } + } + Item::Page { group, .. } => { + if run.first().is_some_and(|f| !matches!(f, Item::Page { group: g, .. } if g == group)) { + close(&mut run, &mut out, &mut used); + } + run.push(*item); + } + } + } + close(&mut run, &mut out, &mut used); + out +} + +/// The entries after moving the pages `moving` (by entry id and place among its pages), in +/// their order, to before page `gap` of the list (counted over every page, an entry that can't be +/// split counting as one; 0 = before the first): a page moved between another file's pages +/// splits its file into parts, and parts of one file brought side by side join. `None` when +/// nothing would change. +pub(crate) fn moved_pages(parts: &[Part], moving: &BTreeSet<(u64, usize)>, gap: usize, new_id: &mut dyn FnMut() -> u64) -> Option> { + let items = items_of(parts); + let gap = gap.min(items.len()); + let picked = |i: &Item| matches!(i, Item::Page { from, nth, .. } if moving.contains(&(*from, *nth))); + let before = items.get(..gap).map_or(0, |s| s.iter().filter(|i| picked(i)).count()); + let (taken, mut rest): (Vec, Vec) = items.iter().partition(|i| picked(i)); + if taken.is_empty() { + return None; + } + let at = gap.saturating_sub(before).min(rest.len()); + rest.splice(at..at, taken); + let out = parts_of(&rest, parts, new_id); + (out != parts).then_some(out) +} + +/// The entries after taking out the pages `removing` (by entry id and place among its pages); +/// an entry left with none goes, and parts of one file left side by side join. `None` when +/// nothing would change. +pub(crate) fn removed_pages(parts: &[Part], removing: &BTreeSet<(u64, usize)>, new_id: &mut dyn FnMut() -> u64) -> Option> { + let items: Vec = + items_of(parts).into_iter().filter(|i| !matches!(i, Item::Page { from, nth, .. } if removing.contains(&(*from, *nth)))).collect(); + let out = parts_of(&items, parts, new_id); + (out != parts).then_some(out) +} + /// Six dots: the drag handle. fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) { for dx in [-2.5, 2.5] { @@ -1505,6 +1694,9 @@ impl PdfCraftApp { /// Select these rows (tests and automation). pub fn select_combine_rows(&mut self, rows: &[usize]) { + // Files picked: no page stays picked (the trash and Delete act on these files). + self.combine_tab.selected_pages.clear(); + self.combine_tab.page_anchor = None; self.combine_tab.selected = rows.iter().filter_map(|i| self.combine_draft.get(*i)).map(|f| f.id).collect(); let last = rows.last().and_then(|i| self.combine_draft.get(*i)).map(|f| f.id); (self.combine_tab.anchor, self.combine_tab.cursor) = (last, last); @@ -1517,6 +1709,8 @@ impl PdfCraftApp { fn combine_restore(&mut self, s: Snapshot) { self.combine_draft = s.files; self.combine_tab.selected = s.selected; + self.combine_tab.selected_pages.clear(); + self.combine_tab.page_anchor = None; self.combine_tab.sort = s.sort; self.combine_tab.range_edit = None; self.combine_tab.revision = self.combine_tab.revision.wrapping_add(1); @@ -1584,6 +1778,115 @@ impl PdfCraftApp { self.combine_tab.sort = Some((key, ascending)); } + /// The list as parts, for page edits: each entry's pages, or `None` for one that can't be + /// split (locked, unreadable, a bad range). + fn combine_parts(&self) -> Vec { + self.combine_draft + .iter() + .map(|f| Part { + id: f.id, + group: f.group, + // (No page listed, e.g. its pages couldn't be counted: it moves whole, never lost.) + pages: (f.lock.is_none() && f.problem.is_none()).then(|| f.pages_taken().ok()).flatten().filter(|p| !p.is_empty()), + }) + .collect() + } + + /// Make the list `parts`, as one undo step: entries kept by id, new parts made from their + /// file's other entries, each taking exactly its pages (its range rewritten). The parts of a + /// file shown as its pages stay shown so; the page selection is let go of. + fn combine_set_parts(&mut self, parts: Vec) { + self.combine_record(); + self.combine_put_parts(parts); + let tab = &mut self.combine_tab; + tab.selected_pages.clear(); + tab.page_anchor = None; + // An order made by hand isn't sorted any more. + tab.sort = None; + } + + /// Join the parts of a file that have come to be side by side (no undo step of its own: it + /// belongs to the change that brought them together). + fn combine_join_parts(&mut self) { + let parts = self.combine_parts(); + // Nothing to join unless some file is in more than one part. + let groups: BTreeSet = parts.iter().map(|p| p.group).collect(); + if groups.len() == parts.len() { + return; + } + let next = &mut self.combine_tab.next_id; + let mut new_id = || { + let id = *next; + *next = next.wrapping_add(1); + id + }; + let joined = parts_of(&items_of(&parts), &parts, &mut new_id); + if joined != parts { + self.combine_put_parts(joined); + } + } + + /// Make the list `parts`: entries kept by id, new parts made from their file's other entries, + /// each taking exactly its pages (its range rewritten only when they differ). + fn combine_put_parts(&mut self, parts: Vec) { + let old = std::mem::take(&mut self.combine_draft); + let expanded: BTreeSet = old.iter().filter(|f| self.combine_tab.expanded.contains(&f.id)).map(|f| f.group).collect(); + for p in parts { + let Some(base) = old.iter().find(|f| f.id == p.id).or_else(|| old.iter().find(|f| f.group == p.group)) else { continue }; + let mut f = base.clone(); + f.id = p.id; + // (A range that already takes these pages stays as the user typed it.) + if let Some(pages) = &p.pages + && f.pages_taken().ok().as_ref() != Some(pages) + { + f.range = range_of(pages, f.pages); + f.checked = None; + } + if expanded.contains(&f.group) { + self.combine_tab.expanded.insert(f.id); + } + self.combine_draft.push(f); + } + let ids: BTreeSet = self.combine_draft.iter().map(|f| f.id).collect(); + self.combine_tab.selected.retain(|id| ids.contains(id)); + } + + /// A page card clicked: that page selected (Ctrl/⌘ toggles it, Shift selects every page from + /// the last one clicked, in the list's order); the files' selection is let go of. + fn combine_click_page(&mut self, id: u64, nth: usize, m: Modifiers) { + let page = (id, nth); + let tab = &mut self.combine_tab; + tab.selected.clear(); + tab.cursor = Some(id); + if m.shift + && let Some(from) = tab.page_anchor + { + // Every page between the two, as the grid shows them. + let order: Vec<(u64, usize)> = self + .combine_draft + .iter() + .filter(|f| tab.expanded.contains(&f.id)) + .flat_map(|f| (0..f.pages_taken().map_or(0, |p| p.len())).map(move |n| (f.id, n))) + .collect(); + let (a, b) = (order.iter().position(|p| *p == from), order.iter().position(|p| *p == page)); + if let (Some(a), Some(b)) = (a, b) { + if !m.command { + tab.selected_pages.clear(); + } + tab.selected_pages.extend(order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied()); + return; + } + } + if m.command { + if !tab.selected_pages.remove(&page) { + tab.selected_pages.insert(page); + } + } else { + tab.selected_pages = BTreeSet::from([page]); + } + tab.page_anchor = Some(page); + } + /// Move `moving` to `gap`, as one undo step; a move that changes nothing leaves the list, /// its sort and the history alone. fn combine_move_to_gap(&mut self, moving: BTreeSet, gap: usize) { @@ -1597,6 +1900,77 @@ impl PdfCraftApp { } pub(crate) fn combine_apply(&mut self, action: RowAction) { + let page_edit = matches!(action, RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_)); + self.combine_apply_once(action); + // One kind of selection at a time: files picked (by a click, the keys, a drop, Unlock…) + // let go of the pages picked, so the trash never takes pages the user no longer means. + if !self.combine_tab.selected.is_empty() { + self.combine_tab.selected_pages.clear(); + self.combine_tab.page_anchor = None; + } + // A file's parts brought side by side (the file between them removed or moved away) are + // one entry again, as in the same undo step. + if !page_edit { + self.combine_join_parts(); + } + } + + fn combine_apply_once(&mut self, action: RowAction) { + // The trash and Delete act on the selected pages while there are any. + let action = match action { + // (Files picked come first: the two are never both picked, but if they ever were, the + // files the user sees picked are what goes.) + RowAction::Remove if self.combine_tab.selected.is_empty() && !self.combine_tab.selected_pages.is_empty() => RowAction::RemovePages(None), + a => a, + }; + // Page edits work on the list as parts. + match action { + RowAction::ClickPage(id, nth, m) => return self.combine_click_page(id, nth, m), + RowAction::MovePages { page, revision, gap } => { + if revision != self.combine_tab.revision { + return; + } + let moving = + if self.combine_tab.selected_pages.contains(&page) { self.combine_tab.selected_pages.clone() } else { BTreeSet::from([page]) }; + let parts = self.combine_parts(); + let next = &mut self.combine_tab.next_id; + let mut new_id = || { + let id = *next; + *next = next.wrapping_add(1); + id + }; + if let Some(moved) = moved_pages(&parts, &moving, gap, &mut new_id) { + self.combine_set_parts(moved); + } + return; + } + RowAction::RemovePages(one) => { + let removing = match one { + Some(page) => BTreeSet::from([page]), + None => self.combine_tab.selected_pages.clone(), + }; + if removing.is_empty() { + return; + } + let parts = self.combine_parts(); + let next = &mut self.combine_tab.next_id; + let mut new_id = || { + let id = *next; + *next = next.wrapping_add(1); + id + }; + // (Nothing to take out: no undo step, the redo history kept.) + if let Some(left) = removed_pages(&parts, &removing, &mut new_id) { + self.combine_set_parts(left); + } + return; + } + _ => {} + } + // A file's card clicked (or the list's keys): the files are selected, no page. + if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { + self.combine_tab.selected_pages.clear(); + } let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); let n = ids.len(); let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); @@ -1731,15 +2105,26 @@ impl PdfCraftApp { } } RowAction::PreviewSelected => { - let id = tab.cursor.filter(|c| tab.selected.contains(c)).or_else(|| ids.iter().copied().find(|id| tab.selected.contains(id))); + // The file the keyboard is on, or the first selected; with pages selected, the + // page card that has the keyboard, or the first selected page. + let first_page = tab.selected_pages.iter().min_by_key(|(id, nth)| (ids.iter().position(|x| x == id), *nth)).copied(); + let page = tab.card_focus_at.filter(|p| tab.selected_pages.contains(p)).or(first_page); + let id = tab + .cursor + .filter(|c| tab.selected.contains(c)) + .or_else(|| ids.iter().copied().find(|id| tab.selected.contains(id))) + .or(page.map(|(id, _)| id)); // Only what its card would offer a magnifier for (unlocked, readable, measured). let shows = |id: u64| self.combine_draft.iter().any(|f| f.id == id && f.lock.is_none() && f.problem.is_none() && !f.sizes.is_empty()); if let Some(id) = id.filter(|id| shows(*id)) { - // From the page card that has the keyboard, if it's one of this file's. - let at = self.combine_tab.card_focus_at.filter(|(file, _)| *file == id).map_or(0, |(_, nth)| nth); + // From the page card that has the keyboard (or the selected page), if it's one + // of this file's. + let at = self.combine_tab.card_focus_at.or(page).filter(|(file, _)| *file == id).map_or(0, |(_, nth)| nth); self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); } } + // (Page edits were done above.) + RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} RowAction::Drop { from, to } => { let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; // Dragging a selected file takes the whole selection along. @@ -1819,6 +2204,7 @@ impl PdfCraftApp { }; let mut f = CombineFile { id: 0, + group: 0, name, bytes, pages: 0, @@ -1840,6 +2226,7 @@ impl PdfCraftApp { self.combine_record(); for mut f in added { f.id = self.combine_tab.next_id; + f.group = f.id; self.combine_tab.next_id = self.combine_tab.next_id.wrapping_add(1); self.combine_draft.push(f); } @@ -1862,24 +2249,30 @@ impl PdfCraftApp { /// Try `password` on the protected files `rows` (as one undo step). Returns false when it /// opened none of them. pub fn combine_unlock(&mut self, rows: &[u64], password: &str) -> bool { + // A file split into parts is unlocked as one: each file once, all its parts alike. + let groups: BTreeSet = + rows.iter().filter_map(|id| self.combine_draft.iter().find(|f| f.id == *id && f.lock.is_some())).map(|f| f.group).collect(); let mut accepted = Vec::new(); - for id in rows { - let Some(f) = self.combine_draft.iter().find(|f| f.id == *id && f.lock.is_some()) else { continue }; + for group in &groups { + let Some(f) = self.combine_draft.iter().find(|f| f.group == *group) else { continue }; if let Ok(a) = assess(&f.bytes, Some(password)) { - accepted.push((*id, a)); + accepted.push((*group, a)); } } if accepted.is_empty() { return false; } self.combine_record(); - let tried = rows.len(); + let tried = groups.len(); let mut unlocked = 0; - for (id, a) in accepted { - if let Some(f) = self.combine_draft.iter_mut().find(|f| f.id == id) { - f.take(a, Some(Secret(password.to_owned()))); - unlocked += usize::from(f.lock.is_none()); + for (group, a) in accepted { + let mut auth = None; + for f in self.combine_draft.iter_mut().filter(|f| f.group == group) { + f.take(a.clone(), Some(Secret(password.to_owned()))); + // Read once: one way of reading for all its parts (their thumbnails are shared). + f.auth = *auth.get_or_insert(f.auth); } + unlocked += usize::from(self.combine_draft.iter().any(|f| f.group == group && f.lock.is_none())); } if tried > 1 { self.notify_fmt("Unlocked {n} of {m} files", &[("n", &unlocked.to_string()), ("m", &tried.to_string())]); @@ -1943,13 +2336,15 @@ impl PdfCraftApp { if files.is_empty() { return; } - let count = files.len(); + // Each file once, however many parts it is in. + let count = files.iter().map(|f| f.group).collect::>().len(); + let groups: Vec = files.iter().map(|f| f.group).collect(); let sources: Vec<(String, Arc>, Option)> = files .iter() .map(|f| (crate::files::strip_pdf(&f.name).to_string(), f.bytes.clone(), Some(f.range.clone()).filter(|r| !r.trim().is_empty()))) .collect(); let passwords: Vec> = files.iter().map(|f| f.password.as_ref().map(|p| p.0.as_str())).collect(); - match self.session.combine_unlocked(&sources, &passwords) { + match self.session.combine_grouped(&sources, &groups, &passwords) { Ok(bytes) => { self.close_combine_tab(); let message = crate::i18n::fmt(tl!("Combined {n} files"), &[("n", &count.to_string())]); @@ -2027,4 +2422,77 @@ mod tests { assert_eq!(now, (0..6).collect::>(), "every file once"); } } + + #[test] + fn page_lists_become_the_shortest_range() { + assert_eq!(range_of(&[0, 1, 2], 3), "", "every page in order: no range"); + assert_eq!(range_of(&[0, 1, 2], 5), "1-3"); + assert_eq!(range_of(&[2, 0, 3], 5), "3, 1, 4"); + assert_eq!(range_of(&[8, 7, 6, 0], 9), "9-7, 1"); + assert_eq!(range_of(&[0, 0, 1], 2), "1, 1-2"); + assert_eq!(range_of(&[4], 9), "5"); + assert_eq!(range_of(&[], 3), ""); + // Whatever comes out reads back as the same pages. + for pages in [vec![0, 1, 2, 5, 4, 3, 3, 7], vec![9, 0, 9, 1], vec![2, 1, 0, 1, 2]] { + let range = range_of(&pages, 10); + let back = pdfcraft_engine::print::select_pages(10, Some(&range), &[], pdfcraft_engine::print::Subset::All, false).unwrap(); + assert_eq!(back, pages, "{range}"); + } + } + + fn part(id: u64, group: u64, pages: &[usize]) -> Part { + Part { id, group, pages: Some(pages.to_vec()) } + } + + #[test] + fn a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin() { + let mut next = 100; + let mut new_id = || { + next += 1; + next + }; + // A: pages 1-3, B: pages 1-2. + let list = [part(1, 1, &[0, 1, 2]), part(2, 2, &[0, 1])]; + // A's page 3 between B's pages: A splits around B's first page. + let moved = moved_pages(&list, &BTreeSet::from([(1, 2)]), 4, &mut new_id).unwrap(); + assert_eq!(moved, [part(1, 1, &[0, 1]), part(2, 2, &[0]), part(101, 1, &[2]), part(102, 2, &[1])]); + // Back where it was: the parts join again, under their first ids. + let back = moved_pages(&moved, &BTreeSet::from([(101, 0)]), 2, &mut new_id).unwrap(); + assert_eq!(back, [part(1, 1, &[0, 1, 2]), part(2, 2, &[0, 1])]); + // Within a file: only its order changes. + let within = moved_pages(&list, &BTreeSet::from([(1, 0)]), 3, &mut new_id).unwrap(); + assert_eq!(within, [part(1, 1, &[1, 2, 0]), part(2, 2, &[0, 1])]); + // Several pages carried together keep their order. + let both = moved_pages(&list, &BTreeSet::from([(1, 0), (1, 2)]), 5, &mut new_id).unwrap(); + assert_eq!(both, [part(1, 1, &[1]), part(2, 2, &[0, 1]), part(103, 1, &[0, 2])]); + // Dropped where it is, or nothing picked: no change. + assert_eq!(moved_pages(&list, &BTreeSet::from([(1, 1)]), 1, &mut new_id), None); + assert_eq!(moved_pages(&list, &BTreeSet::from([(9, 9)]), 0, &mut new_id), None); + // A file that can't be split moves whole, and pages move around it. + let locked = [part(1, 1, &[0, 1]), Part { id: 3, group: 3, pages: None }]; + let around = moved_pages(&locked, &BTreeSet::from([(1, 0)]), 3, &mut new_id).unwrap(); + assert_eq!(around, [part(1, 1, &[1]), Part { id: 3, group: 3, pages: None }, part(104, 1, &[0])]); + } + + #[test] + fn removing_pages_drops_emptied_parts_and_joins_what_meets() { + let mut next = 200; + let mut new_id = || { + next += 1; + next + }; + // A split around B: A[1], B[1], A[2]. + let list = [part(1, 1, &[0]), part(2, 2, &[0]), part(3, 1, &[1])]; + // B goes: A's two parts meet and join. + assert_eq!(removed_pages(&list, &BTreeSet::from([(2, 0)]), &mut new_id), Some(vec![part(1, 1, &[0, 1])])); + // One page of a part. + let two = [part(1, 1, &[0, 1, 2])]; + assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 1)]), &mut new_id), Some(vec![part(1, 1, &[0, 2])])); + // Every page: nothing left. Repeated pages are told apart by their place. + assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 0), (1, 1), (1, 2)]), &mut new_id), Some(Vec::new())); + let repeats = [part(1, 1, &[0, 0])]; + assert_eq!(removed_pages(&repeats, &BTreeSet::from([(1, 1)]), &mut new_id), Some(vec![part(1, 1, &[0])])); + // A page that isn't there: nothing changes, and it says so. + assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 9)]), &mut new_id), None); + } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 630b6f25..e2d397bb 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2187,3 +2187,5 @@ key Space مسافة Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها. Expand all توسيع الكل Collapse all طي الكل + Remove page إزالة الصفحة + Remove {n} pages إزالة {n} صفحات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ed805955..64796bf4 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2127,3 +2127,5 @@ key Space Интервал Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат. Expand all Разгъни всички Collapse all Свий всички + Remove page Премахни страницата + Remove {n} pages Премахни {n} страници diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c010e601..981d6c47 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2165,3 +2165,5 @@ key Space Leertaste Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern. Expand all Alle ausklappen Collapse all Alle einklappen + Remove page Seite entfernen + Remove {n} pages {n} Seiten entfernen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d9aefa6f..aef78de0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2081,3 +2081,5 @@ key Space Espacio Switch to List view to change the pages it takes. Cambia a la vista de lista para cambiar las páginas que se incluyen. Expand all Expandir todo Collapse all Contraer todo + Remove page Quitar página + Remove {n} pages Quitar {n} páginas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6fcd00ab..af009a51 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2165,3 +2165,5 @@ key Space Espace Switch to List view to change the pages it takes. Passez à la vue en liste pour modifier les pages prises. Expand all Tout développer Collapse all Tout réduire + Remove page Retirer la page + Remove {n} pages Retirer {n} pages diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6da6ba9b..48666e9d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2163,3 +2163,5 @@ key Space Szóköz Switch to List view to change the pages it takes. Válts listanézetre, ha módosítanád, mely oldalak kerüljenek bele. Expand all Összes kibontása Collapse all Összes összecsukása + Remove page Oldal eltávolítása + Remove {n} pages {n} oldal eltávolítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cbad9d09..21b6da2a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2174,3 +2174,5 @@ key Space Spazio Switch to List view to change the pages it takes. Passa alla vista elenco per cambiare le pagine incluse. Expand all Espandi tutto Collapse all Comprimi tutto + Remove page Rimuovi pagina + Remove {n} pages Rimuovi {n} pagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8c5db03b..aec23853 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2165,3 +2165,5 @@ key Space スペース Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。 Expand all すべて展開 Collapse all すべて折りたたむ + Remove page ページを削除 + Remove {n} pages {n} ページを削除 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 53302ac4..e4099754 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2166,3 +2166,5 @@ key Space Espaço Switch to List view to change the pages it takes. Mude para a exibição em lista para alterar as páginas incluídas. Expand all Expandir tudo Collapse all Recolher tudo + Remove page Remover página + Remove {n} pages Remover {n} páginas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8bb05402..2b457fba 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2078,3 +2078,5 @@ key Space Пробел Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять. Expand all Развернуть все Collapse all Свернуть все + Remove page Удалить страницу + Remove {n} pages Удалить страницы: {n} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 92f137f6..81e441f1 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2096,3 +2096,5 @@ key Space స్పేస్ Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి Expand all అన్నీ విస్తరించు Collapse all అన్నీ ముడుచు + Remove page పేజీని తీసివేయి + Remove {n} pages {n} పేజీలను తీసివేయి diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fe288b55..f28e3eeb 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2188,3 +2188,5 @@ key Space Пробіл Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти. Expand all Розгорнути всі Collapse all Згорнути всі + Remove page Видалити сторінку + Remove {n} pages Видалити сторінки: {n} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f52ed31..cdd766d5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2129,3 +2129,5 @@ key Space 空格 Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。 Expand all 全部展开 Collapse all 全部折叠 + Remove page 删除页面 + Remove {n} pages 删除 {n} 页 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 15ac4ee8..481f7b96 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2051,3 +2051,5 @@ key Space 空格 Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。 Expand all 全部展開 Collapse all 全部摺疊 + Remove page 刪除頁面 + Remove {n} pages 刪除 {n} 頁 diff --git a/crates/ui-egui/tests/combine_grid.rs b/crates/ui-egui/tests/combine_grid.rs index 43748fd8..8e7ab6bc 100644 --- a/crates/ui-egui/tests/combine_grid.rs +++ b/crates/ui-egui/tests/combine_grid.rs @@ -1369,9 +1369,6 @@ fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { expand(&mut h, "b.pdf"); assert_eq!(disabled(&h, "Undo"), undo, "not an edit: the undo history is as it was"); assert!(disabled(&h, "Redo")); - // Undo undoes what came before (the files' adding), not the expanding. - let before: Vec = names(&h); - assert_eq!(before, ["a.pdf", "b.pdf"]); h.get_by_label("List view").click(); h.run_steps(2); h.get_by_label("Grid view").click(); @@ -1380,10 +1377,14 @@ fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { h.run_steps(2); assert_eq!(h.state().combine_expanded(), [1], "still shown as its pages"); h.get_by_label(&page_card(3, "b.pdf")); - // Its range edited down to one page: its card again (nothing to spread out), and it stays a - // card when the range grows again (no expanding by itself). + // Its range edited down to one page: that page's card (a part of a file can be one page). h.state_mut().combine_draft[1].range = "2".into(); h.run_steps(2); + h.get_by_label(&page_card(2, "b.pdf")); + // A bad range: its card again, no longer expanded, and it doesn't come back expanded by + // itself when the range is fixed. + h.state_mut().combine_draft[1].range = "9".into(); + h.run_steps(2); h.get_by_label("b.pdf"); assert!(h.state().combine_expanded().is_empty(), "no longer expanded"); assert!(disabled(&h, "Collapse all")); @@ -1393,9 +1394,13 @@ fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { assert!(h.state().combine_expanded().is_empty()); expand(&mut h, "b.pdf"); h.get_by_label(&page_card(2, "b.pdf")); - // The file removed: forgotten (undo brings the file back as its card). - h.get_by_label(&page_card(1, "b.pdf")).click(); + // The file removed (its file selected with the keys, then Delete): forgotten; undo brings + // the file back as its card. + h.get_by_label("a.pdf").click(); h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1]); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(names(&h), ["a.pdf"]); @@ -1407,7 +1412,7 @@ fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { } #[test] -fn a_page_selects_its_file_and_the_arrows_step_rows_of_cards() { +fn a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards() { // a, then b's 6 pages, then eight more files: rows of cards below b's last page whatever // the window's width. let mut files = vec![("a.pdf".to_string(), fixture(1)), ("b.pdf".to_string(), fixture(6))]; @@ -1422,14 +1427,24 @@ fn a_page_selects_its_file_and_the_arrows_step_rows_of_cards() { assert!((2..5).contains(&cols), "a row of cards is shorter than a + b's pages ({cols})"); h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); - assert_eq!(h.state().combine_selection(), [1], "a page selects its file"); - assert!(h.get_by_label(&page_card(5, "b.pdf")).accesskit_node().is_selected() == Some(true), "all its pages show selected"); + assert_eq!(h.state().combine_selected_pages(), [(1, 1)], "that page"); + assert!(h.state().combine_selection().is_empty(), "no file"); + assert!(h.get_by_label(&page_card(2, "b.pdf")).accesskit_node().is_selected() == Some(true)); + assert!(h.get_by_label(&page_card(5, "b.pdf")).accesskit_node().is_selected() == Some(false), "only that page"); + // Ctrl adds, Shift takes a run. + h.get_by_label(&page_card(4, "b.pdf")).click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(h.state().combine_selected_pages(), [(1, 1), (1, 3)]); + h.get_by_label(&page_card(6, "b.pdf")).click_modifiers(Modifiers::SHIFT); + h.run_steps(2); + assert_eq!(h.state().combine_selected_pages(), [(1, 3), (1, 4), (1, 5)], "from the last page clicked"); // Down goes a row of cards from b's last page, to the file below it; Up comes back to b. let last = card(&h, &page_card(6, "b.pdf")); h.key_press(Key::ArrowDown); h.run_steps(2); let below = h.state().combine_selection(); assert_eq!(below.len(), 1); + assert!(h.state().combine_selected_pages().is_empty(), "the keys select files again"); let name = names(&h)[below[0]].clone(); assert!(below[0] > 1, "a file after b: {name}"); let r = card(&h, &name); @@ -1443,20 +1458,35 @@ fn a_page_selects_its_file_and_the_arrows_step_rows_of_cards() { } #[test] -fn dragging_a_page_moves_its_whole_file_and_drops_go_to_a_file_edge() { +fn dragging_a_page_moves_that_page_even_between_another_files_pages() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4), ("c.pdf", 1)]); expand(&mut h, "b.pdf"); - // A page of b dragged after c: all of b moves. + // b's page 2 dragged after c: that page alone moves; b is now in two parts. let (p2, c) = (card(&h, &page_card(2, "b.pdf")), card(&h, "c.pdf")); drag(&mut h, p2.center(), c.center() + vec2(c.width() * 0.3, 0.0)); - assert_eq!(names(&h), ["a.pdf", "c.pdf", "b.pdf"]); - // a dropped among b's pages: before b when nearer its start, after it when nearer its end. - let (a, early) = (card(&h, "a.pdf"), card(&h, &page_card(2, "b.pdf"))); - drag(&mut h, a.center(), early.center() - vec2(early.width() * 0.3, 0.0)); - assert_eq!(names(&h), ["c.pdf", "a.pdf", "b.pdf"], "page 2 of 4: the start of b"); - let (a, late) = (card(&h, "a.pdf"), card(&h, &page_card(4, "b.pdf"))); - drag(&mut h, a.center(), late.center() - vec2(late.width() * 0.3, 0.0)); - assert_eq!(names(&h), ["c.pdf", "b.pdf", "a.pdf"], "page 4 of 4: the end of b"); + let parts = + |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]", "c.pdf:[0]", "b.pdf:[1]"]); + // One undo step puts it back, as one file again. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); + // Within its file: only its order changes (page 4 to the front). + let (p4, p1) = (card(&h, &page_card(4, "b.pdf")), card(&h, &page_card(1, "b.pdf"))); + drag(&mut h, p4.center(), p1.center() - vec2(p1.width() * 0.3, 0.0)); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[3, 0, 1, 2]", "c.pdf:[0]"]); + // Selected pages go together, in their order: pages 1 and 3 (now 2nd and 4th) before a. + h.get_by_label(&page_card(1, "b.pdf")).click(); + h.run_steps(1); + h.get_by_label(&page_card(3, "b.pdf")).click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + let (p3, a) = (card(&h, &page_card(3, "b.pdf")), card(&h, "a.pdf")); + drag(&mut h, p3.center(), a.center() - vec2(a.width() * 0.3, 0.0)); + assert_eq!(parts(&h), ["b.pdf:[0, 2]", "a.pdf:[0]", "b.pdf:[3, 1]", "c.pdf:[0]"]); + // A file's card still moves its whole file, and a drop among a file's pages goes to its nearer end. + let (cc, early) = (card(&h, "c.pdf"), card(&h, &page_card(3, "b.pdf"))); + drag(&mut h, cc.center(), early.center() - vec2(early.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "b.pdf"], "the second of that part's two pages: its end"); } #[test] @@ -1493,8 +1523,9 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); h.get_by_label("Page 3 of 3"); - // Page cards have no trash (removing single pages comes later); nothing is selected yet, so - // no "Remove b.pdf" anywhere. + // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from + // it); nothing is selected yet, so no "Remove b.pdf" anywhere. + h.get_by_label("Remove page"); h.key_press(Key::Escape); h.run_steps(2); hover_card(&mut h, &page_card(4, "b.pdf")); @@ -1567,3 +1598,168 @@ fn a_file_too_long_to_spread_out_offers_no_expand() { h.run_steps(2); assert_eq!(h.state().combine_expanded(), [1], "only the file that can be"); } + +#[test] +fn removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file() { + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); + let original = h.state().combine_draft[1].bytes.clone(); + expand(&mut h, "b.pdf"); + let parts = + |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); + // A page card's trash: that page. + hover_card(&mut h, &page_card(2, "b.pdf")); + h.get_by_label("Remove page").click(); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]"]); + // Two selected: the toolbar's trash says so, and takes both. + h.get_by_label(&page_card(1, "b.pdf")).click(); + h.run_steps(1); + h.get_by_label(&page_card(4, "b.pdf")).click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(1); + h.get_by_label("Remove 2 pages").click(); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[2]"]); + // Delete on the last page left: the file leaves the list. + h.get_by_label(&page_card(3, "b.pdf")).click(); + h.run_steps(2); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]"]); + // Each was one undo step; the file itself was never changed. + for expected in [vec!["a.pdf:[0]", "b.pdf:[2]"], vec!["a.pdf:[0]", "b.pdf:[0, 2, 3]"], vec!["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]"]] { + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(parts(&h), expected); + } + assert!(std::sync::Arc::ptr_eq(&h.state().combine_draft[1].bytes, &original), "the same, untouched bytes"); +} + +#[test] +fn a_split_file_counts_once_and_combines_with_one_bookmark() { + let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 3)]); + expand(&mut h, "one.pdf"); + expand(&mut h, "two.pdf"); + let totals = |h: &Harness<'static, PdfCraftApp>| { + let n = h.get_by_label_contains(" pages · "); + let a = n.accesskit_node(); + a.label().or(a.value()).unwrap_or_default().to_string() + }; + let before = totals(&h); + assert!(before.starts_with("2 files · 5 pages · "), "{before}"); + // one's page 2 between two's pages 1 and 2. + let (p2, t2) = (card(&h, &page_card(2, "one.pdf")), card(&h, &page_card(2, "two.pdf"))); + drag(&mut h, p2.center(), t2.center() - vec2(t2.width() * 0.3, 0.0)); + assert_eq!(names(&h), ["one.pdf", "two.pdf", "one.pdf", "two.pdf"]); + // Still two files, of the same size (each counted once), and the parts aren't "added more + // than once". + assert_eq!(totals(&h), before); + hover_card(&mut h, &page_card(2, "one.pdf")); + for _ in 0..60 { + h.run_steps(1); + } + assert!(h.query_by_label_contains("Added more than once").is_none()); + h.hover_at(egui::pos2(5.0, 890.0)); + h.run_steps(2); + h.get_by_label("Combine").click(); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1, "the result opens"); + assert_eq!(texts_of(app, 0), ["Page 1", "Page 1", "Page 2", "Page 2", "Page 3"], "the order shown"); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::>(), ["one", "two"], "one bookmark per file"); +} + +#[test] +fn pages_picked_out_of_sight_or_before_an_undo_are_let_go_of() { + // Regression: a page picked, then hidden (its file collapsed, or the list shown) or undone, + // stayed picked, and the trash or Delete took it. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4), ("c.pdf", 1)]); + let parts = + |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); + expand(&mut h, "b.pdf"); + // Collapsed from its hover bar: nothing picked, Delete takes nothing. + h.get_by_label(&page_card(2, "b.pdf")).click(); + h.run_steps(2); + hover_card(&mut h, &page_card(3, "b.pdf")); + h.get_by_label("Collapse").click(); + h.run_steps(2); + assert!(h.state().combine_selected_pages().is_empty()); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); + // In the list: the same. + expand(&mut h, "b.pdf"); + h.get_by_label(&page_card(2, "b.pdf")).click(); + h.run_steps(2); + h.get_by_label("List view").click(); + h.run_steps(2); + assert!(h.state().combine_selected_pages().is_empty()); + h.get_by_label("Grid view").click(); + h.run_steps(2); + // After an undo: let go of, and a Delete then changes nothing and keeps the redo. + let (p2, c) = (card(&h, &page_card(2, "b.pdf")), card(&h, "c.pdf")); + drag(&mut h, p2.center(), c.center() + vec2(c.width() * 0.3, 0.0)); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]", "c.pdf:[0]", "b.pdf:[1]"]); + // Page 3, the second of b's first part: after the undo that place is page 2, another page. + h.get_by_label(&page_card(3, "b.pdf")).click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert!(h.state().combine_selected_pages().is_empty()); + assert!(!disabled(&h, "Redo")); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); + assert!(!disabled(&h, "Redo"), "a Delete with nothing picked makes no undo step"); + // A file picked (by dragging its card) lets go of the pages: Delete takes the file. + h.get_by_label(&page_card(2, "b.pdf")).click(); + h.run_steps(2); + let (cc, a) = (card(&h, "c.pdf"), card(&h, "a.pdf")); + drag(&mut h, cc.center(), a.center() - vec2(a.width() * 0.3, 0.0)); + assert_eq!(h.state().combine_selection(), [0]); + assert!(h.state().combine_selected_pages().is_empty()); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]"]); +} + +#[test] +fn a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined() { + let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 1)]); + let parts = + |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); + expand(&mut h, "one.pdf"); + // one's page 2 after two: one is in two parts. + let (p2, two) = (card(&h, &page_card(2, "one.pdf")), card(&h, "two.pdf")); + drag(&mut h, p2.center(), two.center() + vec2(two.width() * 0.3, 0.0)); + assert_eq!(parts(&h), ["one.pdf:[0]", "two.pdf:[0]", "one.pdf:[1]"]); + // two goes: one's parts meet and are one entry again, in the same undo step. + hover_card(&mut h, "two.pdf"); + h.get_by_label("Remove two.pdf").click(); + h.run_steps(2); + assert_eq!(parts(&h), ["one.pdf:[0, 1]"]); + // One file is nothing to combine, however it is split. + assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(parts(&h), ["one.pdf:[0]", "two.pdf:[0]", "one.pdf:[1]"]); +} + +#[test] +fn files_picked_by_automation_let_go_of_the_pages_picked() { + // Regression (found by review): selecting rows through the API left a page picked, and + // Delete took that page instead of the file. + let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 3)]); + expand(&mut h, "b.pdf"); + h.get_by_label(&page_card(2, "b.pdf")).click(); + h.run_steps(2); + assert_eq!(h.state().combine_selected_pages(), [(1, 1)]); + h.state_mut().select_combine_rows(&[0]); + h.run_steps(2); + assert!(h.state().combine_selected_pages().is_empty()); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(h.state().combine_parts_shown(), [("b.pdf".to_string(), vec![0, 1, 2])], "the file picked goes, b keeps its pages"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8d19d4da..7ae51735 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2926,8 +2926,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments"] -notes = "" +evidence = ["crates/organize/src/tests.rs::combine_nests_source_bookmarks_and_keeps_attachments", "crates/organize/src/tests.rs::a_file_split_around_another_is_copied_once_with_one_bookmark", "crates/organize/src/tests.rs::grouped_runs_keep_their_order_repeats_and_separate_files", "crates/engine/src/tests.rs::a_file_split_around_another_combines_in_order_with_one_bookmark", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/automation/tests/automation.rs::combine_extract_and_split"] +notes = "Also when a file's pages are split around other files' pages (Combine files grid): the file is copied once, so it keeps one bookmark at its first page, its nested bookmarks, links between its own pages, its form fields and one copy of its attachments. doc_combine does the same with groups." [[feature]] id = "organize.combine-choose-pages" @@ -2962,8 +2962,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_its_whole_file_and_drops_go_to_a_file_edge", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_selects_its_file_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes"] -notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a page selects its file, Up/Down step rows of cards, dragging a page moves its whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. Missing: moving or removing single pages before combining (#611)." +evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list", "crates/ui-egui/tests/combine_grid.rs::expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers", "crates/ui-egui/tests/combine_grid.rs::expand_all_and_collapse_all_from_the_toolbar_and_a_right_click", "crates/ui-egui/tests/combine_grid.rs::expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step", "crates/ui-egui/tests/combine_grid.rs::dragging_a_page_moves_that_page_even_between_another_files_pages", "crates/ui-egui/tests/combine_grid.rs::combining_with_files_expanded_gives_the_same_document", "crates/ui-egui/tests/combine_grid.rs::a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards", "crates/ui-egui/tests/combine_grid.rs::a_page_cards_magnifier_opens_the_preview_on_that_page", "crates/ui-egui/tests/combine_grid.rs::a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads", "crates/ui-egui/tests/combine_grid.rs::collapsing_brings_the_files_card_into_view", "crates/ui-egui/tests/combine_grid.rs::a_file_too_long_to_spread_out_offers_no_expand", "crates/ui-egui/src/combine_grid.rs::the_next_page_of_a_file_reuses_its_open_document_until_it_closes", "crates/ui-egui/tests/combine_grid.rs::removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file", "crates/ui-egui/tests/combine_grid.rs::a_split_file_counts_once_and_combines_with_one_bookmark", "crates/ui-egui/src/combine_ui.rs::a_page_moved_between_another_files_pages_splits_its_file_and_parts_rejoin", "crates/ui-egui/src/combine_ui.rs::removing_pages_drops_emptied_parts_and_joins_what_meets", "crates/ui-egui/src/combine_ui.rs::page_lists_become_the_shortest_range", "crates/ui-egui/tests/combine_grid.rs::pages_picked_out_of_sight_or_before_an_undo_are_let_go_of", "crates/ui-egui/tests/combine_grid.rs::a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined", "crates/organize/src/tests.rs::a_page_shown_twice_gets_its_own_annotations", "crates/engine/src/tests.rs::a_page_with_a_form_field_shown_twice_keeps_one_field", "crates/ui-egui/tests/combine_grid.rs::files_picked_by_automation_let_go_of_the_pages_picked"] +notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. A file that adds more than one page (up to 2000) expands into its pages (hover bar, right-click, or Expand all / Collapse all in the toolbar): one card per page it adds, in its range's order, each saying Page N and the file's name, on a tinted band; a click on a page selects that page, Up/Down step rows of cards, dragging a file's card moves the whole file (a drop among a file's pages goes to its nearer end), and its magnifier opens the preview on that page; expanding is a view, not an undo step, and changes nothing in the combined file; a page's render reuses its file's open document for a moment rather than reading the file again. With a file expanded, single pages are selected (click, Ctrl, Shift), dragged anywhere (a page moved between another file's pages splits its file into parts, shown as rows in the list with their own page ranges; parts side by side join again) and removed (a page card's trash, the toolbar, Delete), each one undo step, never changing the files themselves; a split file still counts and combines as one file." [[feature]] id = "organize.combine-options"