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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@opencode.ai>

* 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 <noreply@anthropic.com>

* 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 <noreply@opencode.ai>

* 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 <noreply@opencode.ai>

* 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 <noreply@opencode.ai>

* 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 <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@opencode.ai>

* 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 <noreply@anthropic.com>

* 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 <noreply@opencode.ai>

* 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 <echelon@gmail.com>

* 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 <echelon@gmail.com>

* fix(automation): retain batch results with identical filenames (#877)

* fix(automation): retain batch results with identical filenames

* Format

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M13: preserve desktop Action Wizard batch results (#886)

* M13: preserve desktop Action Wizard batch results

Co-Authored-By: Codex <noreply@openai.com>

* ROADMAP: date the desktop action outputs entry today

---------

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@opencode.ai>

* 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
<vertices>x,y;x,y;…</vertices>, and import reads them back (whole x,y pairs
only) before the appearance is drawn.

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* XFDF keeps a callout's leader, text-box inset, intent and arrowhead (#819) (#854)

Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* M10.2: scan JPEG metadata beyond frame headers (#764)

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <gyurix@users.noreply.github.com>

* 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 <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <echelon@gmail.com>

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REz7wTfQgnopyvN9eQJJVu

---------

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 `<occur min="10"/>` 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 <hadichokr@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

---------

Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* M14: localize displayed shortcut key names (#496) (#884)

Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* 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 <noreply@anthropic.com>

---------

Signed-off-by: Hadi Chokr <hadichokr@icloud.com>
Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
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 <noreply@opencode.ai>
Co-authored-by: mlkonrad <marlonk@gmail.com>
Co-authored-by: Tristan von Kries <tristanvonkries@gmail.com>
Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
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 <barrett.wallace@gmail.com>
Co-authored-by: Tevinhead <173904641+Tevinhead@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com>
Co-authored-by: DWAA1660 <dwatnip123@gmail.com>
Co-authored-by: DWAA1660 <DWAA1660@users.noreply.github.com>
Co-authored-by: Justin John <34035011+justinjohn0306@users.noreply.github.com>
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
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 <b25d025@akita-pu.ac.jp>
Co-authored-by: gyurix <barathgy@gmail.com>
Co-authored-by: GyuriX <gyurix@users.noreply.github.com>
Co-authored-by: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: CyberFox-81 <brittondcouchman@gmail.com>
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 <hadichokr@icloud.com>
This commit is contained in:
taylorandrew07-lab 2026-10-10 16:50:18 -04:00 committed by GitHub
parent 5268047a33
commit 26dec1030b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 876 additions and 157 deletions

View File

@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (260)
## In this repository (261)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -134,6 +134,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |

View File

@ -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 = "5d982ffe0c946c4dc9930901a23e0a6221cff8ecd8481f704b06003719cd6fe0"
sha256 = "92b20307c6f461c4b057e9b2895122f876a2dd48b4101788f2cd47793df70499"
[[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 = "eb612b49df91777bbb89625eb0257707a389720349b018fc954afe7ebfb02970"
sha256 = "c9b4aace4495fb998f3bb3aa233e20ce48f9d7fdf5ca35c0481b438881eddd62"
[[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 = "9b6ef3dd77e51982a8e23eb4e4182c16c590553b060ab141283e851e93ee2041"
sha256 = "7bbf7cf6c65cde241a87c4b0c975cc21ed070c025b00f299deafcc3426e1bce4"
[[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 = "7708e9b9a12d1896261e3e010addd61b4313c5126959f5b9d613d67ee469cc33"
sha256 = "210f0b8f4bff3f5b9cc22f240536bad68cd1a7de3958f2553dfb54d60555c07e"
[[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 = "36bf3703711e7730a2b6995e6949f120bf0a4ff7ef6836c129d396d66ee61cac"
sha256 = "6eab259f84f0774a6cd816d04ce7eb16aa44a4004b25fbe2676ce9fef4fb386c"
[[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 = "89e5a0f086d176a92addd6dfa327aa7e9ee619e6cab640a3353c07390c45575c"
sha256 = "f1e14339af91885fdf94d4f800ffee428c355d2368a3d8d56695638692021c44"
[[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 = "1e24555846cacfc8e84ed6900e2a495ecf994bf7bd96bc01733b48507518d2a3"
sha256 = "01a6eff838ad9d34bac63c9c9228ac03c58826ea08e8c63cd26724da8ca00fd5"
[[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 = "2772eb00b4ccdf378bf311ca82510202a4b9b6c948706378e3c319c8aa0713a2"
sha256 = "2daadafd5af3156e6dd269aed320525100a0a305de756c92b14e6078c14efb82"
[[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 = "e273af2513c3bc8db051b7f80fedeaae36aaba53486e54f608fc227497d43a7f"
sha256 = "27e1f490a6e57e0b7e923d62451e934abcafc66944015d13b009e76a3c598863"
[[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 = "4c044110ea4feef81e8561b510bd4bcf89beabc3e32a42b94265cf3d46692b72"
sha256 = "e913893a535c009df6de3909c1a2c7322293ccdc42ff97b962762d8d8d702a3d"
[[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 = "d20714c060877abcea56c7d0f638a5bf1e31f698d606086d741985c03b10ff7a"
sha256 = "2109aafdefadde26f2a9da3842c08f1bf58cd920f932cb0d15e40b55f7f86b1b"
[[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 = "361e015a2ebfb507d4c59f5b2450c9112fe0a7e3d4ad5ba77e88f33f473bdd75"
sha256 = "503ab86bef47616ead8f03db29bc3f629d31eaad7c4f708e2d5c1f4cda190ee8"
[[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 = "48114bed18d577a6aa00dab59241ad26a22d369b41cc4f60cecd716e7921dbc4"
sha256 = "3576db171a132c4494d0bf37531cf0b817f527528e42e8323cad42bc516a9e90"
[[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 = "e073dfb9aa269b5174b5b9e997629ffd988cc310afb461d6fa3185a9da050776"
sha256 = "32a866873b2ad4575dc2c25acba14b71d2065110239839948e6577c4a7fb3fb1"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -1396,6 +1396,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "fe917d6144a9aed9a5297cb28addb9e582ac591a8f1c498a55aecea9b5c1783e"
[[asset]]
path = "assets/icons/minimize-2.svg"
title = "Lucide icon \"minimize-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "1150437d5a870321c59b7ebca2e1bc3f6227f0fa03dd10484ab384dfe5110739"
[[asset]]
path = "assets/icons/minus.svg"
title = "Lucide icon \"minus\""

View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m14 10 7-7" />
<path d="M20 10h-6V4" />
<path d="m3 21 7-7" />
<path d="M4 14h6v6" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@ -61,11 +61,19 @@ pub(crate) fn zoom_step(zoom: f32, larger: bool) -> f32 {
}
/// Space above the first row of cards.
const TOP: f32 = 12.0;
/// The page shown large (a card's magnifier) is kept beside the thumbnails under this key, so it
/// A thumbnail's place: the file (by id) and the page (0-based, in the file). A file's card and
/// the card of its first page when expanded show the same thumbnail.
type Slot = (u64, usize);
/// The page shown large (a card's magnifier) is kept beside the thumbnails under this slot, so it
/// shares their threads, their limits and their checks.
const PREVIEW: u64 = u64::MAX;
const PREVIEW: Slot = (u64::MAX, 0);
/// The longest side of the page shown large, in pixels.
const PREVIEW_SIDE: f32 = 2048.0;
/// A file whose render is done keeps its document open this long (seconds), so the next page of
/// the same file renders without reading the whole file again.
const KEEP_OPEN: f64 = 2.0;
/// The most pages one file can be spread out into (each is a card, laid out every frame).
pub(crate) const MAX_EXPAND: usize = 2000;
/// The shape drawn for a page whose size is unknown (US Letter); nothing is rendered for it.
const FALLBACK_PAGE: (f32, f32) = (612.0, 792.0);
/// Icons drawn on the (always white) paper, in either theme: the light theme's muted text, which
@ -120,15 +128,25 @@ struct Entry {
}
struct Job {
file: u64,
slot: Slot,
key: Key,
pool: RenderPool,
tag: u64,
}
/// A pool whose render is done, with its document still open: kept a moment for the next page of
/// the same file, read the same way.
struct Open {
source: usize,
auth: u64,
pool: RenderPool,
/// When its render finished (seconds).
since: f64,
}
/// A thumbnail a card needs this frame.
struct Want {
file: u64,
slot: Slot,
key: Key,
bytes: Arc<Vec<u8>>,
password: Option<Arc<str>>,
@ -139,8 +157,10 @@ struct Want {
/// The grid's thumbnails and the renders under way.
#[derive(Default)]
pub(crate) struct Thumbs {
entries: HashMap<u64, Entry>,
entries: HashMap<Slot, Entry>,
jobs: Vec<Job>,
/// Pools done rendering, their documents still open (their threads count).
open: Vec<Open>,
/// Threads of pools let go of, until they exit.
retiring: Vec<Retiring>,
frame: u64,
@ -168,20 +188,24 @@ impl Thumbs {
for job in self.jobs.drain(..) {
self.retiring.push(job.pool.retire());
}
for open in self.open.drain(..) {
self.retiring.push(open.pool.retire());
}
}
/// Render threads that exist now (real threads, not pools): those of pools at work and of
/// pools let go of.
/// Render threads that exist now (real threads, not pools): those of pools at work, of pools
/// keeping a document open, and of pools let go of.
fn threads(&mut self) -> usize {
self.retiring.retain_mut(|r| r.running() > 0);
let retiring: usize = self.retiring.iter_mut().map(Retiring::running).sum();
let working: usize = self.jobs.iter().map(|j| j.pool.threads()).sum();
working.saturating_add(retiring)
let open: usize = self.open.iter().map(|o| o.pool.threads()).sum();
working.saturating_add(open).saturating_add(retiring)
}
fn look(&mut self, file: u64, key: Key) -> Look {
fn look(&mut self, slot: Slot, key: Key) -> Look {
let frame = self.frame;
match self.entries.get_mut(&file) {
match self.entries.get_mut(&slot) {
Some(e) if e.key == key => {
e.seen = frame;
match (&e.tex, &e.error) {
@ -203,8 +227,8 @@ impl Thumbs {
}
}
fn state(&self, file: u64) -> ThumbState {
if let Some(e) = self.entries.get(&file) {
fn state(&self, slot: Slot) -> ThumbState {
if let Some(e) = self.entries.get(&slot) {
if let Some(tex) = &e.tex {
let [w, h] = tex.size();
return ThumbState::Ready {
@ -217,19 +241,26 @@ impl Thumbs {
return ThumbState::Failed(err.clone());
}
}
if self.jobs.iter().any(|j| j.file == file) { ThumbState::Pending } else { ThumbState::None }
if self.jobs.iter().any(|j| j.slot == slot) { ThumbState::Pending } else { ThumbState::None }
}
/// Once per frame, whatever shows: drop what no longer matches the list, take finished
/// renders, start those the grid asked for and keep the textures within budget.
fn finish(&mut self, ctx: &egui::Context, current: &HashMap<u64, Key>) {
/// renders, start those the grid asked for and keep the textures within budget. `current`
/// says what a thumbnail must match now (looked up, so the cost follows the files and the
/// thumbnails kept, not the pages of an expanded file).
fn finish(&mut self, ctx: &egui::Context, current: &dyn Fn(Slot) -> Option<Key>) {
let now = ctx.input(|i| i.time);
// Reconcile with the list as it is now (undo, redo, unlock, a new range, removal): a
// thumbnail of another page, password or file is never shown again.
self.entries.retain(|file, e| current.get(file).is_some_and(|k| k.same_page(&e.key)));
self.entries.retain(|slot, e| current(*slot).is_some_and(|k| k.same_page(&e.key)));
// Renders nobody wants any more are let go of now (on the web, before they would render).
let (keep, gone): (Vec<Job>, Vec<Job>) = self.jobs.drain(..).partition(|job| current.get(&job.file).is_some_and(|k| k.same_page(&job.key)));
let (keep, gone): (Vec<Job>, Vec<Job>) = self.jobs.drain(..).partition(|job| current(job.slot).is_some_and(|k| k.same_page(&job.key)));
self.jobs = keep;
self.retiring.extend(gone.into_iter().map(|job| job.pool.retire()));
// Documents kept open past their time are closed.
let (keep, gone): (Vec<Open>, Vec<Open>) = self.open.drain(..).partition(|o| now - o.since < KEEP_OPEN);
self.open = keep;
self.retiring.extend(gone.into_iter().map(|o| o.pool.retire()));
// Finished renders, a few per frame. A pool without threads (the web) renders inside
// `try_recv`: at most one of those per frame.
@ -259,6 +290,8 @@ impl Thumbs {
}
let job = self.jobs.swap_remove(i);
taken += 1;
// A pool that rendered (rather than failed or hung) can keep its document open.
let healthy = answer.as_ref().is_some_and(|out| out.error.is_none());
let failed = |e: String| Entry { key: job.key, tex: None, error: Some(e), seen: self.frame, bytes: 0 };
let entry = match answer {
None => failed("the page wasn't drawn".to_owned()),
@ -266,10 +299,10 @@ impl Thumbs {
Some(e) => failed(e),
None => match texture(
ctx,
job.file,
job.slot,
[out.width, out.height],
out.rgba,
if job.file == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side },
if job.slot == PREVIEW { gpu_side.min(PREVIEW_SIDE as usize) } else { max_side },
) {
Ok((tex, bytes)) => {
uploaded = uploaded.saturating_add(bytes);
@ -279,15 +312,20 @@ impl Thumbs {
},
},
};
self.entries.insert(job.file, entry);
// Its thread (idle now, or still finishing a render the watchdog gave up on) counts
// until it exits.
self.retiring.push(job.pool.retire());
self.entries.insert(job.slot, entry);
// Its document stays open a moment for the file's next page (its thread, idle, still
// counts); otherwise its thread (idle, or finishing a render the watchdog gave up on)
// counts until it exits.
if healthy && job.pool.threads() <= 1 && self.open.len() < MAX_THREADS {
self.open.push(Open { source: job.key.source, auth: job.key.auth, pool: job.pool, since: now });
} else {
self.retiring.push(job.pool.retire());
}
}
// New renders: the cards in view first, in grid order; one per file at a time.
// New renders: the cards in view first, in grid order; one per thumbnail at a time.
let mut wants = std::mem::take(&mut self.wants);
wants.sort_by_key(|w| (w.file != PREVIEW, !w.visible));
wants.sort_by_key(|w| (w.slot != PREVIEW, !w.visible));
// A pool without threads (the web) parses its document when it is made and keeps it until
// it renders, one per frame: while one waits, nothing else starts.
// (Nor in the frame that rendered one: one heavy step per frame on the UI thread.)
@ -295,14 +333,37 @@ impl Thumbs {
let mut waiting = false;
for w in wants {
// Wanted for the list as it is now (not changed by this frame's action); one render
// per file at a time; and a want made earlier this frame may have just been answered.
if current.get(&w.file).is_none_or(|k| !k.same_page(&w.key))
|| self.jobs.iter().any(|j| j.file == w.file)
|| self.entries.get(&w.file).is_some_and(|e| e.key == w.key)
// per thumbnail at a time; and a want made earlier this frame may have just been
// answered.
if current(w.slot).is_none_or(|k| !k.same_page(&w.key))
|| self.jobs.iter().any(|j| j.slot == w.slot)
|| self.entries.get(&w.slot).is_some_and(|e| e.key == w.key)
{
continue;
}
// The same file, read the same way, still open from a render just done: no new read of
// the file (and its thread is already counted). On the web, not while another waits.
let inline_waiting = self.jobs.iter().any(|j| j.pool.is_inline());
if let Some(k) =
self.open.iter().position(|o| o.source == w.key.source && o.auth == w.key.auth && !(o.pool.is_inline() && inline_waiting))
{
let pool = self.open.swap_remove(k).pool;
self.next_tag = self.next_tag.wrapping_add(1);
let tag = self.next_tag;
pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]);
if pool.is_inline() {
threads = MAX_THREADS;
}
self.jobs.push(Job { slot: w.slot, key: w.key, pool, tag });
continue;
}
if threads >= MAX_THREADS {
// A document kept open for another file gives its thread up: it exits, and this
// starts then.
if let Some(k) = self.open.iter().enumerate().min_by(|(_, a), (_, b)| a.since.total_cmp(&b.since)).map(|(k, _)| k) {
let open = self.open.swap_remove(k);
self.retiring.push(open.pool.retire());
}
waiting = true;
break;
}
@ -316,25 +377,25 @@ impl Thumbs {
// The request stays in the pool's queue (it is never replaced) until its answer.
pool.set_queue(vec![RenderRequest { page: w.key.page, scale: w.scale, tag, ..Default::default() }]);
threads = if pool.is_inline() { MAX_THREADS } else { threads.saturating_add(1) };
self.jobs.push(Job { file: w.file, key: w.key, pool, tag });
self.jobs.push(Job { slot: w.slot, key: w.key, pool, tag });
}
// Within budget: drop the thumbnails out of view longest unseen first.
// (The page shown large has its own: one texture, gone when it closes.)
let mut total: usize = self.entries.iter().filter(|(f, _)| **f != PREVIEW).map(|(_, e)| e.bytes).sum();
if total > TEXTURE_BUDGET {
let mut old: Vec<(u64, u64, usize)> = self
let mut old: Vec<(u64, Slot, usize)> = self
.entries
.iter()
.filter(|(f, e)| **f != PREVIEW && e.seen != self.frame && e.bytes > 0)
.map(|(f, e)| (e.seen, *f, e.bytes))
.collect();
old.sort_unstable();
for (_, file, bytes) in old {
for (_, slot, bytes) in old {
if total <= TEXTURE_BUDGET {
break;
}
self.entries.remove(&file);
self.entries.remove(&slot);
total = total.saturating_sub(bytes);
}
}
@ -348,6 +409,9 @@ impl Thumbs {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
} else if waiting {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else if !self.open.is_empty() {
// To close the documents kept open when their time is up.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(KEEP_OPEN));
}
self.frame = self.frame.wrapping_add(1);
}
@ -357,7 +421,7 @@ impl Thumbs {
/// larger than a thumbnail can be (or than the GPU accepts), is refused, never uploaded.
fn texture(
ctx: &egui::Context,
file: u64,
slot: Slot,
size: [u32; 2],
pixels: pdfcraft_render::Pixels,
max_side: usize,
@ -371,7 +435,7 @@ fn texture(
return Err("the thumbnail came back incomplete".to_owned());
}
let image = crate::canvas::texture_image([w, h], pixels);
Ok((ctx.load_texture(format!("combine-thumb-{file}"), image, egui::TextureOptions::LINEAR), bytes))
Ok((ctx.load_texture(format!("combine-thumb-{}-{}", slot.0, slot.1), image, egui::TextureOptions::LINEAR), bytes))
}
/// `name` shortened in the middle to what `fits`, keeping its extension ("Pilot Boat Ad….pdf").
@ -416,6 +480,79 @@ fn columns_in(width: f32, cell: Vec2) -> usize {
((width - 16.0) / cell.x).floor().max(1.0) as usize
}
/// What one place in the grid shows: a file's card, or (the file expanded) one of the pages it
/// adds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Cell {
/// The file, by its place in the list.
pub(crate) file: usize,
/// For a page: its place among the pages the file adds, the page (0-based, in the file) and
/// how many it adds.
pub(crate) page: Option<(usize, usize, usize)>,
}
/// The pages an expanded file shows: in its range's order, and sorted (to look one up), for the
/// range and page count they were worked out for.
#[derive(Clone, Debug)]
pub(crate) struct PageList {
of: (String, usize),
pub(crate) order: Arc<[usize]>,
sorted: Arc<[usize]>,
}
/// 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`]).
pub(crate) fn pages_shown(cache: &mut HashMap<u64, PageList>, f: &CombineFile) -> Option<PageList> {
if f.lock.is_some() || f.problem.is_some() {
return None;
}
let list = match cache.get(&f.id) {
Some(list) if list.of.0 == f.range && list.of.1 == f.pages => list.clone(),
_ => {
let order = f.pages_taken().ok()?;
let mut sorted = order.clone();
sorted.sort_unstable();
let list = PageList { of: (f.range.clone(), f.pages), order: Arc::from(order), sorted: Arc::from(sorted) };
cache.insert(f.id, list.clone());
list
}
};
(list.order.len() > 1 && 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.
fn layout(app: &mut PdfCraftApp) -> Vec<Cell> {
let tab = &mut app.combine_tab;
let mut cells = Vec::with_capacity(app.combine_draft.len());
for (file, f) in app.combine_draft.iter().enumerate() {
match tab.expanded.contains(&f.id).then(|| pages_shown(&mut tab.page_lists, f)).flatten() {
Some(list) => {
let of = list.order.len();
cells.extend(list.order.iter().enumerate().map(|(nth, &page)| Cell { file, page: Some((nth, page, of)) }));
}
None => cells.push(Cell { file, page: None }),
}
}
cells
}
/// The file gap (0 = before the first file, n = after the last) a drop before place `at` means: a
/// drop inside an expanded file's pages goes to the nearer end of that file.
fn file_gap(cells: &[Cell], at: usize, files: usize) -> usize {
match cells.get(at) {
None => files,
Some(Cell { file, page: Some((nth, _, of)) }) if *nth > 0 => {
if nth.saturating_mul(2) < *of {
*file
} else {
file.saturating_add(1)
}
}
Some(c) => c.file,
}
}
/// The grid of files, in the card that holds the list. Returns what the user did.
pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result<usize, String>], selected: &[bool]) -> Option<RowAction> {
let ctx = ui.ctx().clone();
@ -432,6 +569,15 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
*copies.entry((f.name.as_str(), f.bytes.len())).or_default() += 1;
}
let twice: Vec<bool> = app.combine_draft.iter().map(|f| copies.get(&(f.name.as_str(), f.bytes.len())).is_some_and(|n| *n > 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];
for (at, c) in places.iter().enumerate().rev() {
if let Some(slot) = first_place.get_mut(c.file) {
*slot = at;
}
}
app.combine_tab.grid_places = places.iter().map(|c| c.file).collect();
let reveal = app.combine_tab.reveal.take();
let fit = paper_box(app.combine_zoom);
let cell = fit + AROUND;
@ -469,7 +615,7 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
width = ui.available_width();
let cols = columns_in(width, cell);
*columns = cols;
let rows = n.div_ceil(cols);
let rows = places.len().div_ceil(cols);
let left = ((width - cols as f32 * cell.x) / 2.0).max(0.0);
let (area, _) = ui.allocate_exact_size(vec2(width, rows as f32 * cell.y), Sense::hover());
let cell_rect = |i: usize| {
@ -477,44 +623,72 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
Rect::from_min_size(pos2(area.left() + left + col as f32 * cell.x, area.top() + row as f32 * cell.y), cell)
};
if let Some(i) = reveal.and_then(|id| files.iter().position(|f| f.id == id)) {
ui.scroll_to_rect(cell_rect(i), None);
ui.scroll_to_rect(cell_rect(first_place.get(i).copied().unwrap_or(0)), None);
}
for row in crate::canvas::thumbnail_rows(clip.top() - TOP, clip.bottom() - TOP, cell.y, rows) {
for col in 0..cols {
let i = row * cols + col;
let Some(f) = files.get_mut(i) else { break };
let first = row.saturating_mul(cols);
let in_row = places.get(first..first.saturating_add(cols).min(places.len())).unwrap_or_default();
// An expanded file's pages on this row share a tinted band, so they read as one file.
let mut run = 0;
while run < in_row.len() {
let Some(start) = in_row.get(run).copied() else { break };
let mut end = run;
while in_row.get(end.saturating_add(1)).is_some_and(|c| c.file == start.file && c.page.is_some()) {
end = end.saturating_add(1);
}
if start.page.is_some() {
let band = cell_rect(first.saturating_add(run)).union(cell_rect(first.saturating_add(end))).shrink2(vec2(2.0, 4.0));
ui.painter().rect_filled(band, CornerRadius::same(10), t.accent_soft.gamma_multiply(0.45));
}
run = end.saturating_add(1);
}
for (col, place) in in_row.iter().enumerate() {
let i = first.saturating_add(col);
let Some(f) = files.get_mut(place.file) else { break };
let c = cell_rect(i);
cells.push((i, c));
let card = Card {
i,
i: place.file,
page: place.page,
rect: c,
check: checks.get(i).cloned().unwrap_or(Ok(f.pages)),
selected: selected.get(i).copied().unwrap_or(false),
twice: twice.get(i).copied().unwrap_or(false),
check: checks.get(place.file).cloned().unwrap_or(Ok(f.pages)),
selected: selected.get(place.file).copied().unwrap_or(false),
twice: twice.get(place.file).copied().unwrap_or(false),
revision,
ppp,
fit,
};
let card_id = ui.id().with(("combine-card", f.id));
let card_id = card.id(ui, f);
if let Some(a) = card.show(ui, t, f, thumbs) {
action = Some(a);
}
if ui.memory(|m| m.has_focus(card_id)) {
focused_card = Some(card_id);
focused_card = Some((card_id, f.id, place.page.map_or(0, |p| p.0)));
}
}
}
// While a card is dragged: the gap it would go to, drawn as a bar, and how many go.
// While a card is dragged: the file gap it would go to, drawn as a bar, and how many go.
if let Some(d) = drag
&& let Some(p) = pointer
{
let gap = crate::canvas::drop_gap(&cells, p);
// Drawn at the near edge of the card nearest the pointer (as `drop_gap` decides), so a
// gap at the end of a row shows on that row, not at the start of the next.
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 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.
if let Some(gap) = gap {
let before = gap
.checked_sub(1)
.and_then(|f| first_place.get(f.saturating_add(1)).map(|next| next.saturating_sub(1)).or(places.len().checked_sub(1)));
let after = first_place.get(gap).copied();
let ends = [before.map(|at| cell_rect(at).right() - 2.0).zip(before), after.map(|at| cell_rect(at).left() + 2.0).zip(after)];
let nearest = ends.into_iter().flatten().min_by(|(xa, a), (xb, b)| {
let da = pos2(*xa, cell_rect(*a).center().y).distance_sq(p);
let db = pos2(*xb, cell_rect(*b).center().y).distance_sq(p);
da.total_cmp(&db)
});
if let Some((x, at)) = nearest {
let r = cell_rect(at);
ui.painter().line_segment([pos2(x, r.top() + 10.0), pos2(x, r.bottom() - 30.0)], Stroke::new(3.0, t.accent));
}
}
let moving = files
.iter()
@ -532,7 +706,8 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
}
});
app.combine_tab.card_focus = focused_card;
app.combine_tab.card_focus = focused_card.map(|(id, ..)| id);
app.combine_tab.card_focus_at = focused_card.map(|(_, file, nth)| (file, nth));
// Pinch, or Ctrl/⌘ with the wheel, over the grid zooms it.
let pinch = ctx.input(|i| i.zoom_delta());
@ -549,25 +724,28 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
app.set_combine_zoom(zoom);
if app.combine_zoom != before {
// The same card all through one gesture (a slider drag, a turn of the wheel): the
// first in view changes as the cards reflow, and following it would drift.
// first in view changes as the cards reflow, and following it would drift. A card is
// kept by its file and its place among the file's pages.
let now = ctx.input(|i| i.time);
let gesture = app
.combine_tab
.zoom_anchor
.filter(|a| now - a.2 < 0.5)
.and_then(|(id, above, _)| app.combine_draft.iter().position(|f| f.id == id).map(|i| (i, above)));
let gesture = app.combine_tab.zoom_anchor.filter(|a| now - a.3 < 0.5).and_then(|(id, nth, above, _)| {
let file = app.combine_draft.iter().position(|f| f.id == id)?;
Some((first_place.get(file)?.saturating_add(nth).min(places.len().saturating_sub(1)), above))
});
// Cards just outside the view are drawn too (under the toolbar, say): the pointer
// counts only over the grid.
let under = pointer.filter(|_| over_grid).and_then(|p| cells.iter().find(|(_, r)| r.contains(p)));
let anchor = gesture
.or_else(|| under.or_else(|| cells.iter().find(|(_, r)| r.bottom() > viewport.top())).map(|(i, r)| (*i, r.top() - viewport.top())));
app.combine_tab.zoom_anchor = anchor.and_then(|(i, above)| app.combine_draft.get(i).map(|f| (f.id, above, now)));
app.combine_tab.zoom_anchor = anchor.and_then(|(i, above)| {
let place = places.get(i)?;
Some((app.combine_draft.get(place.file)?.id, place.page.map_or(0, |p| p.0), above, now))
});
if let Some((i, above)) = anchor {
// Where it will be: its row at the new size, as far below the top as it is now;
// within what can be scrolled to, so zooming out at the bottom shows no gap.
let cell = paper_box(app.combine_zoom) + AROUND;
let cols = columns_in(width, cell);
let content = TOP + n.div_ceil(cols) as f32 * cell.y;
let content = TOP + places.len().div_ceil(cols) as f32 * cell.y;
let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0);
// Set as the scroll position itself, before the next frame draws anything: no
// jump, and no scroll still animating (to a card revealed by the keys, say) can
@ -585,7 +763,11 @@ pub(crate) fn grid(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks:
/// One card's place and what it shows.
struct Card {
/// The file, by its place in the list.
i: usize,
/// A page card (the file expanded): its place among the pages the file adds, the page and how
/// many it adds. `None` for the file's own card.
page: Option<(usize, usize, usize)>,
rect: Rect,
check: Result<usize, String>,
selected: bool,
@ -597,16 +779,34 @@ struct Card {
}
impl Card {
fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id {
match self.page {
None => ui.id().with(("combine-card", f.id)),
Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)),
}
}
fn show(&self, ui: &mut egui::Ui, t: &Tokens, f: &mut CombineFile, thumbs: &mut Thumbs) -> Option<RowAction> {
let mut action = None;
let c = self.rect;
let id = ui.id().with(("combine-card", f.id));
let id = self.id(ui, f);
let resp = ui.interact(c.shrink(4.0), id, Sense::click_and_drag());
let name = f.name.clone();
// A page card: "Page 3", and the file it belongs to.
let page_label = self.page.map(|(_, p, _)| crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.saturating_add(1).to_string())]));
let label = match &page_label {
Some(page) => format!("{page} · {name}"),
None => name.clone(),
};
// As the organize grid's pages: a check box that is on when selected; and, for screen
// readers, selected as items in a list are.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &name));
let pages = if f.pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]) };
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label));
let pages = match self.page {
// For screen readers: which page of how many.
Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]),
None if f.pages == 1 => tl!("1 page").to_string(),
None => crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]),
};
let painter = ui.painter_at(c);
// Selected and hovered cards.
@ -618,16 +818,19 @@ impl Card {
}
// The page, fitted in the paper box (top-aligned, room above for the stack).
let page = f.first_page();
let page = match self.page {
Some((_, p, _)) => Ok(p),
None => f.first_page(),
};
// The page's own size, when it is known and is one.
let known = page.as_ref().ok().and_then(|p| f.sizes.get(*p).copied()).filter(|(w, h)| paper_size(*w, *h, self.fit).is_some());
let size_pt = known.unwrap_or(FALLBACK_PAGE);
let size = paper_size(size_pt.0, size_pt.1, self.fit).unwrap_or(self.fit);
let top = c.top() + ABOVE;
let paper = Rect::from_min_size(pos2(c.center().x - size.x / 2.0 - 3.0, top + (self.fit.y - size.y)), size);
// More than one page: sheets behind it.
// A file's card that adds more than one page: sheets behind it.
let takes = self.check.as_ref().map_or(f.pages, |k| *k);
if takes > 1 {
if self.page.is_none() && takes > 1 {
for d in [6.0, 3.0] {
let sheet = paper.translate(vec2(d, -d));
painter.rect_filled(sheet, CornerRadius::ZERO, Color32::WHITE);
@ -661,7 +864,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, key);
let look = thumbs.look((f.id, p), key);
let mut want = false;
match look {
Look::Ready(tex) => {
@ -677,7 +880,7 @@ impl Card {
}
if want && scale.is_finite() && scale > 0.0 {
thumbs.wants.push(Want {
file: f.id,
slot: (f.id, p),
key,
bytes: f.bytes.clone(),
password: f.password().map(Arc::from),
@ -702,22 +905,29 @@ impl Card {
});
painter.rect_stroke(paper, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
// A warning that doesn't stop it being combined: a badge on the corner.
let warned = !f.notes.is_empty() || self.twice;
// A warning that doesn't stop it being combined: a badge on the file's card.
let warned = self.page.is_none() && (!f.notes.is_empty() || self.twice);
if warned && why.as_ref().is_none_or(|(icon, ..)| *icon == "eye-off") {
let r = Rect::from_center_size(paper.right_top() + vec2(-2.0, 2.0), Vec2::splat(18.0));
painter.circle_filled(r.center(), 10.0, t.card);
icons::paint(ui, r, "triangle-alert", 15.0, WARNING);
}
// The name, shortened in the middle to fit, and the pages taken when a range is set.
let font = theme::medium(12.5);
let max_w = c.width() - 20.0;
let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= max_w;
let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned();
// The name, shortened in the middle to fit, and the pages taken when a range is set; on a
// page card, the page first and the file's name below it, smaller.
let name_y = top + self.fit.y + 16.0;
painter.text(pos2(c.center().x, name_y), Align2::CENTER_CENTER, shown, font, t.text);
let note = if !f.range.trim().is_empty() {
let max_w = c.width() - 20.0;
let (font, colour, y) = match &page_label {
Some(page) => {
painter.text(pos2(c.center().x, name_y), Align2::CENTER_CENTER, page, theme::semibold(12.5), t.text);
(theme::regular(11.5), t.text_muted, name_y + 17.0)
}
None => (theme::medium(12.5), t.text, name_y),
};
let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w;
let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned();
painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour);
let note = if self.page.is_none() && !f.range.trim().is_empty() {
match &self.check {
Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)),
Err(_) => Some((tl!("Check the pages").to_owned(), ERROR)),
@ -733,30 +943,52 @@ impl Card {
// dragged): remove this file only, and show it large when there is a page to show. Drawn
// over the card, so a click on one doesn't select or move it.
let previewable = why.is_none();
// A file that adds more pages than one can be spread out into them.
let expandable = self.page.is_none() && takes > 1 && takes <= MAX_EXPAND && f.lock.is_none() && f.problem.is_none() && self.check.is_ok();
// Shown large: the file from its first page, or a page card's page.
let preview = match self.page {
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.
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() {
buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false)));
} else {
buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id)));
}
if previewable {
buttons.push(("search", tl!("Preview").to_owned(), preview));
}
// Nothing of the card shows over a dialog (the preview, the password box).
let covered = ui.ctx().memory(|m| m.top_modal_layer().is_some());
let mut over_bar = false;
if !covered && ui.rect_contains_pointer(c.shrink(4.0)) && !egui::DragAndDrop::has_any_payload(ui.ctx()) {
let count = if previewable { 2.0 } else { 1.0 };
let count = buttons.len() as f32;
// Mostly above the page (a quarter over its top edge), so it hides little of it, and
// never the name below.
let y = paper.top() + BAR_H / 4.0 - BAR_H / 2.0;
let bar = Rect::from_center_size(pos2(paper.center().x, y), vec2(count * 26.0 + (count - 1.0) * 2.0 + 8.0, BAR_H));
let bar = Rect::from_center_size(pos2(paper.center().x, y), vec2(count * 26.0 + (count - 1.0).max(0.0) * 2.0 + 8.0, BAR_H));
over_bar = ui.rect_contains_pointer(bar);
painter.rect(bar, CornerRadius::same(8), t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink(4.0)).layout(egui::Layout::left_to_right(egui::Align::Center)));
bar_ui.spacing_mut().item_spacing.x = 2.0;
let remove = crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]);
if icons::button(&mut bar_ui, "trash-2", 26.0, false, &remove).clicked() {
action = Some(RowAction::RemoveFile(f.id));
}
if previewable && icons::button(&mut bar_ui, "search", 26.0, false, tl!("Preview")).clicked() {
action = Some(RowAction::Preview(f.id));
for (icon, label, act) in &buttons {
if icons::button(&mut bar_ui, icon, 26.0, false, label).clicked() {
action = Some(act.clone());
}
}
}
// The full name and everything known about the file, on hover.
let mut tip = format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len()));
// The full name and everything known about the file, on hover (on a page card, which page).
let mut tip = match &page_label {
Some(page) => format!("{}\n{page}", f.name),
None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())),
};
for line in why.iter().map(|(_, _, s)| s).chain(&f.notes) {
tip.push('\n');
tip.push_str(line);
@ -766,28 +998,26 @@ impl Card {
tip.push_str(tl!("Added more than once"));
}
// Page ranges are typed in the list.
if self.check.is_err() {
if self.page.is_none() && self.check.is_err() {
tip.push('\n');
tip.push_str(tl!("Switch to List view to change the pages it takes."));
}
// (Over the bar, its buttons say what they do instead.)
let resp = if covered || over_bar { resp } else { resp.on_hover_text(tip) };
// The same actions on a right click (and Unlock… for a locked file).
// The same actions on a right click, show large first (and Unlock… for a locked file).
let at = resp.rect.left_bottom();
let (file, locked) = (f.id, f.lock.is_some());
let locked = f.lock.is_some();
resp.context_menu(|ui| {
let item = |ui: &mut egui::Ui, icon: &str, label: &str| {
ui.add(egui::Button::image_and_text(icons::image(icon, 15.0, ui.visuals().text_color()), label)).clicked()
};
if previewable && item(ui, "search", tl!("Preview")) {
action = Some(RowAction::Preview(file));
ui.close();
}
// This file only, as its trash button, and named so.
if item(ui, "trash-2", &crate::i18n::fmt(tl!("Remove {name}"), &[("name", &name)])) {
action = Some(RowAction::RemoveFile(file));
ui.close();
for (icon, label, act) in buttons.iter().rev() {
// (Remove names its file, as the trash button does.)
if item(ui, icon, label) {
action = Some(act.clone());
ui.close();
}
}
if locked && item(ui, "lock-open", tl!("Unlock…")) {
action = Some(RowAction::Unlock(Some(self.i), at));
@ -900,13 +1130,13 @@ 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).filter(|e| e.key.page == page && e.key.auth == f.auth).and_then(|e| e.tex.as_ref());
let card = thumbs.entries.get(&(f.id, 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);
}
if scale.is_finite() && scale > 0.0 {
thumbs.wants.push(Want {
file: PREVIEW,
slot: PREVIEW,
key,
bytes: f.bytes.clone(),
password: f.password().map(Arc::from),
@ -964,32 +1194,63 @@ impl PdfCraftApp {
/// while it is drawn), and let go of what no longer matches the list, so renders under way
/// finish and release their threads in the list view, on Home or in a document tab too.
pub(crate) fn combine_thumbs_frame(&mut self, ctx: &egui::Context) {
// What each file's thumbnail must match now (cards out of view included).
let current: HashMap<u64, Key> = self
.combine_draft
.iter_mut()
.filter_map(|f| {
let page = f.first_page().ok()?;
Some((f.id, Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] }))
})
.collect();
// 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).
struct Shown {
source: usize,
auth: u64,
first: usize,
sorted: Option<Arc<[usize]>>,
}
let mut shown: HashMap<u64, Shown> = 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 });
}
// The page shown large, while it shows: leaving the tab closes it.
if !self.combine_showing() {
self.combine_tab.preview = None;
}
let mut current = current;
if let Some(p) = &self.combine_tab.preview
&& let Some(page) = p.page
&& let Some(f) = self.combine_draft.iter().find(|f| f.id == p.file)
{
current.insert(PREVIEW, Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] });
}
let preview = self.combine_tab.preview.as_ref().and_then(|p| {
let page = p.page?;
let f = self.combine_draft.iter().find(|f| f.id == p.file)?;
Some(Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page, px: [0, 0] })
});
let current = |slot: Slot| -> Option<Key> {
if slot == PREVIEW {
return preview;
}
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],
})
};
self.combine_thumbs.finish(ctx, &current);
}
/// 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<usize> {
self.combine_draft.iter().enumerate().filter(|(_, f)| self.combine_tab.expanded.contains(&f.id)).map(|(i, _)| i).collect()
}
/// An expanded file's page cards: each page (0-based, in the file) and its thumbnail; empty
/// for a file shown as its card (tests and the control channel).
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()
}
/// Each listed file's grid thumbnail, in list order (tests and the control channel).
pub fn combine_thumbnails(&self) -> Vec<ThumbState> {
self.combine_draft.iter().map(|f| self.combine_thumbs.state(f.id)).collect()
self.combine_draft.iter().map(|f| f.first_taken().map_or(ThumbState::None, |p| self.combine_thumbs.state((f.id, p)))).collect()
}
/// Thumbnail renders under way (tests: never more than a few).
@ -1148,16 +1409,16 @@ mod tests {
}
fn want(file: u64, bytes: &Arc<Vec<u8>>) -> Want {
Want { file, key: key_of(bytes), bytes: bytes.clone(), password: None, scale: 0.15, visible: true }
Want { slot: (file, 0), key: key_of(bytes), bytes: bytes.clone(), password: None, scale: 0.15, visible: true }
}
fn listing(files: &[(u64, &Arc<Vec<u8>>)]) -> HashMap<u64, Key> {
files.iter().map(|(f, b)| (*f, key_of(b))).collect()
fn listing(files: &[(u64, &Arc<Vec<u8>>)]) -> HashMap<Slot, Key> {
files.iter().map(|(f, b)| ((*f, 0), key_of(b))).collect()
}
/// One frame of `finish`, as the app runs it; how soon it asks to be drawn again.
fn frame(ctx: &egui::Context, thumbs: &mut Thumbs, current: &HashMap<u64, Key>) -> std::time::Duration {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| thumbs.finish(ui.ctx(), current));
fn frame(ctx: &egui::Context, thumbs: &mut Thumbs, current: &HashMap<Slot, Key>) -> std::time::Duration {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| thumbs.finish(ui.ctx(), &|slot| current.get(&slot).copied()));
// No GPU here: the texture uploads are taken as done.
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay)
@ -1176,7 +1437,7 @@ mod tests {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let delay = frame(&ctx, &mut thumbs, &current);
if thumbs.entries.get(&1).is_some_and(|e| e.tex.is_some()) {
if thumbs.entries.get(&(1, 0)).is_some_and(|e| e.tex.is_some()) {
assert_eq!(delay, std::time::Duration::ZERO, "the frame that took the result asks for another");
break;
}
@ -1217,11 +1478,11 @@ mod tests {
// Seen at frames 1, 5 and 9 (out of view now), and 10 (in view), 20 MB each as counted.
for (i, seen) in [1u64, 5, 9, 10].into_iter().enumerate() {
let key = key_of(&files[i]);
thumbs.entries.insert(i as u64, Entry { key, tex: Some(tex(&format!("t{i}"))), error: None, seen, bytes: 20 << 20 });
thumbs.entries.insert((i as u64, 0), Entry { key, tex: Some(tex(&format!("t{i}"))), error: None, seen, bytes: 20 << 20 });
}
let current: HashMap<u64, Key> = files.iter().enumerate().map(|(i, b)| (i as u64, key_of(b))).collect();
let current: HashMap<Slot, Key> = files.iter().enumerate().map(|(i, b)| ((i as u64, 0), key_of(b))).collect();
frame(&ctx, &mut thumbs, &current);
let mut kept: Vec<u64> = thumbs.entries.keys().copied().collect();
let mut kept: Vec<u64> = thumbs.entries.keys().map(|k| k.0).collect();
kept.sort_unstable();
assert_eq!(kept, [3], "80 MB: the three out of view go, oldest first; the one in view stays even over budget");
}
@ -1268,6 +1529,41 @@ mod tests {
assert!(thumbs.entries.values().filter(|e| e.tex.is_some()).count() == 6, "all six drawn, one heavy step per frame");
}
#[test]
fn the_next_page_of_a_file_reuses_its_open_document_until_it_closes() {
// Regression: each page of an expanded file read (parsed) the whole file again.
let ctx = egui::Context::default();
let mut thumbs = Thumbs::default();
let bytes = pdf(3, 200, 300);
let first = key_of(&bytes);
let second = Key { page: 1, ..first };
let current: HashMap<Slot, Key> = [((1, 0), first), ((1, 1), second)].into_iter().collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let drawn = |thumbs: &Thumbs, slot: Slot| thumbs.entries.get(&slot).is_some_and(|e| e.tex.is_some());
while !drawn(&thumbs, (1, 0)) {
assert!(std::time::Instant::now() < deadline, "page 1 is drawn");
thumbs.wants.push(want(1, &bytes));
frame(&ctx, &mut thumbs, &current);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(thumbs.open.len(), 1, "its document stays open a moment");
// Page 2 of the same file, read the same way: that open document renders it.
thumbs.wants.push(Want { slot: (1, 1), key: second, ..want(1, &bytes) });
frame(&ctx, &mut thumbs, &current);
assert!(thumbs.open.is_empty() && thumbs.jobs.len() == 1, "reused, not a new pool: {} open, {} jobs", thumbs.open.len(), thumbs.jobs.len());
while !drawn(&thumbs, (1, 1)) {
assert!(std::time::Instant::now() < deadline, "page 2 is drawn");
frame(&ctx, &mut thumbs, &current);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(thumbs.threads() <= MAX_THREADS);
// Unused, it closes after a while, and its thread goes.
while thumbs.threads() > 0 || !thumbs.open.is_empty() {
assert!(std::time::Instant::now() < deadline, "the open document closes and its thread exits");
frame(&ctx, &mut thumbs, &current);
}
}
#[test]
fn a_page_that_failed_is_not_tried_again_at_another_size() {
// Regression: each zoom step asked for a new render of a page that had failed (or hung
@ -1276,12 +1572,12 @@ mod tests {
let mut thumbs = Thumbs::default();
let bytes = pdf(1, 200, 300);
let key = key_of(&bytes);
thumbs.entries.insert(1, Entry { key, tex: None, error: Some("broken".into()), seen: 0, bytes: 0 });
thumbs.entries.insert((1, 0), Entry { key, tex: None, error: Some("broken".into()), seen: 0, bytes: 0 });
let larger = Key { px: [key.px[0] + 64, key.px[1] + 64], ..key };
assert!(matches!(thumbs.look(1, larger), Look::Failed(e) if e == "broken"), "the failure stands at another size");
assert!(matches!(thumbs.look((1, 0), larger), Look::Failed(e) if e == "broken"), "the failure stands at another size");
// Another read of the file (a new password) is a new page: tried again.
let reread = Key { auth: 2, ..key };
assert!(matches!(thumbs.look(1, reread), Look::Pending(None)));
assert!(matches!(thumbs.look((1, 0), reread), Look::Pending(None)));
}
#[test]
@ -1293,14 +1589,14 @@ mod tests {
let (silent, next) = (pdf(1, 200, 300), pdf(1, 200, 300));
let pool = RenderPool::new_inline(silent.clone(), RenderConfig::default());
// Nothing queued: this pool has nothing to answer with.
thumbs.jobs.push(Job { file: 1, key: key_of(&silent), pool, tag: 7 });
thumbs.jobs.push(Job { slot: (1, 0), key: key_of(&silent), pool, tag: 7 });
let current = listing(&[(1, &silent), (2, &next)]);
for _ in 0..4 {
thumbs.wants.push(want(2, &next));
frame(&ctx, &mut thumbs, &current);
}
assert!(thumbs.entries.get(&1).is_some_and(|e| e.tex.is_none() && e.error.is_some()), "the silent one failed");
assert!(thumbs.entries.get(&2).is_some_and(|e| e.tex.is_some()), "the next one was drawn");
assert!(thumbs.entries.get(&(1, 0)).is_some_and(|e| e.tex.is_none() && e.error.is_some()), "the silent one failed");
assert!(thumbs.entries.get(&(2, 0)).is_some_and(|e| e.tex.is_some()), "the next one was drawn");
assert!(thumbs.jobs.is_empty());
assert!(frame(&ctx, &mut thumbs, &current) > std::time::Duration::ZERO, "no more frames asked for at once");
}

View File

@ -5,7 +5,7 @@
//! column heading; PDFs dropped on the tab join the list. Every change to the list can be undone.
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use std::time::SystemTime;
@ -189,14 +189,25 @@ pub struct CombineTab {
pub(crate) zoom_request: Option<f32>,
/// The card size slider, which takes the arrow keys while it has the keyboard.
pub(crate) zoom_slider: Option<egui::Id>,
/// The card kept in place through a zoom gesture: its file, how far below the top of the grid
/// it was, and when the grid last zoomed (seconds).
pub(crate) zoom_anchor: Option<(u64, f32, f64)>,
/// The card kept in place through a zoom gesture: its file, its place among the file's pages
/// (0 for the file's own card), how far below the top of the grid it was, and when the grid
/// last zoomed (seconds).
pub(crate) zoom_anchor: Option<(u64, usize, f32, f64)>,
/// Files shown as their pages in the grid (a view, not an edit: no undo step).
pub(crate) expanded: BTreeSet<u64>,
/// The pages each expanded file shows, worked out again only when its range changes.
pub(crate) page_lists: HashMap<u64, crate::combine_grid::PageList>,
/// The file (by place in the list) at each place of the grid as last drawn: Up and Down step
/// a row of cards, page cards included.
pub(crate) grid_places: Vec<usize>,
/// A file shown large (a card's magnifier), while it shows.
pub(crate) preview: Option<Preview>,
/// The card that had the keyboard last frame: Space shows its file large; on any other focused
/// widget Space is that widget's.
pub(crate) card_focus: Option<egui::Id>,
/// 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)>,
}
/// The pages a range takes, and the range and page count they were worked out for.
@ -392,6 +403,14 @@ impl CombineFile {
self.checked_range().map(|(_, first)| first)
}
/// The first page the range takes, without updating the cache (read-only callers).
pub(crate) fn first_taken(&self) -> Option<usize> {
match &self.checked {
Some((range, result)) if *range == self.range => result.as_ref().ok().map(|(_, first)| *first),
_ => self.pages_taken().ok()?.first().copied(),
}
}
/// Every page (0-based) the range takes, in its order: what the preview flips through.
pub(crate) fn pages_taken(&self) -> Result<Vec<usize>, String> {
if self.range.trim().is_empty() {
@ -439,6 +458,7 @@ impl CombineFile {
}
}
#[derive(Clone)]
pub(crate) enum RowAction {
/// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it).
Click(usize, Modifiers),
@ -459,6 +479,13 @@ pub(crate) enum RowAction {
Preview(u64),
/// Space: show the file the keyboard is on (or the first selected) large.
PreviewSelected,
/// A page card's magnifier: show its file large, from that page (its place among the pages
/// the file adds).
PreviewAt(u64, usize),
/// Show one file as its pages in the grid (`true`), or as its card again.
Expand(u64, bool),
/// Every file that adds more than one page, as its pages (`true`), or all as cards again.
ExpandAll(bool),
/// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`.
Drop {
from: usize,
@ -495,6 +522,16 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let n = app.combine_draft.len();
let ids: Vec<u64> = app.combine_draft.iter().map(|f| f.id).collect();
app.combine_tab.selected.retain(|id| ids.contains(id));
// 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.
{
let tab = &mut app.combine_tab;
let draft = &app.combine_draft;
let lists = &mut tab.page_lists;
tab.expanded.retain(|id| draft.iter().find(|f| f.id == *id).is_some_and(|f| crate::combine_grid::pages_shown(lists, f).is_some()));
let expanded = &tab.expanded;
tab.page_lists.retain(|id, _| expanded.contains(id));
}
let selected: Vec<bool> = ids.iter().map(|id| app.combine_tab.selected.contains(id)).collect();
let count = selected.iter().filter(|s| **s).count();
let blocker = blocker(&app.combine_draft, &checks);
@ -610,6 +647,16 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} selected"), &[("n", &count.to_string())])).color(t.text_muted));
}
if grid && n > 0 {
// Every file as its pages, or all as cards again.
group(ui, &t, edge, 2.0 * 30.0 + 6.0);
let can_expand = app.combine_draft.iter_mut().any(|f| !app.combine_tab.expanded.contains(&f.id) && expandable(f));
if ui.add_enabled_ui(can_expand, |ui| icons::button(ui, "maximize-2", 30.0, false, tl!("Expand all"))).inner.clicked() {
events.action = Some(RowAction::ExpandAll(true));
}
let can_collapse = !app.combine_tab.expanded.is_empty();
if ui.add_enabled_ui(can_collapse, |ui| icons::button(ui, "minimize-2", 30.0, false, tl!("Collapse all"))).inner.clicked() {
events.action = Some(RowAction::ExpandAll(false));
}
zoom_controls(app, ui, &t, edge);
}
});
@ -795,6 +842,11 @@ fn tool(ui: &mut egui::Ui, enabled: bool, icon: &str, label: &str, disabled_why:
ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, label)).inner.on_disabled_hover_text(disabled_why)
}
/// Whether a file can be shown as its pages: readable, unlocked, and adding more than one.
fn expandable(f: &mut CombineFile) -> bool {
f.lock.is_none() && f.problem.is_none() && f.selection().is_ok_and(|k| k > 1 && k <= crate::combine_grid::MAX_EXPAND)
}
/// Why Combine can't run yet, if so.
fn blocker(files: &[CombineFile], checks: &[Result<usize, String>]) -> Option<String> {
if files.len() < 2 {
@ -1574,7 +1626,15 @@ impl PdfCraftApp {
tab.selected = ids.iter().copied().collect();
}
RowAction::Step { down, by, extend } => {
// With files shown as their pages, Up and Down go a row of cards (from the last
// of a file's cards down, from its first up) to the file there.
let places = &tab.grid_places;
let rows_of_cards = by > 1 && places.len() > n;
let next = match index_of(tab.cursor) {
Some(c) if rows_of_cards => {
let from = if down { places.iter().rposition(|f| *f == c) } else { places.iter().position(|f| *f == c) };
from.and_then(|at| places.get(step_to(at, places.len(), by, down)).copied()).unwrap_or(c)
}
Some(c) => step_to(c, n, by, down),
None if down => 0,
None => n.saturating_sub(1),
@ -1647,12 +1707,37 @@ impl PdfCraftApp {
self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None });
}
}
RowAction::PreviewAt(id, at) => {
if ids.contains(&id) {
self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None });
}
}
RowAction::Expand(id, on) => {
if on {
if self.combine_draft.iter_mut().any(|f| f.id == id && expandable(f)) {
self.combine_tab.expanded.insert(id);
}
} else {
self.combine_tab.expanded.remove(&id);
// Its card may be far from where its pages were: brought into view.
self.combine_tab.reveal = Some(id);
}
}
RowAction::ExpandAll(on) => {
self.combine_tab.expanded =
if on { self.combine_draft.iter_mut().filter_map(|f| expandable(f).then_some(f.id)).collect() } else { BTreeSet::new() };
if !on {
self.combine_tab.reveal = self.combine_tab.cursor;
}
}
RowAction::PreviewSelected => {
let id = tab.cursor.filter(|c| tab.selected.contains(c)).or_else(|| ids.iter().copied().find(|id| tab.selected.contains(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)) {
self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None });
// 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);
self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None });
}
}
RowAction::Drop { from, to } => {

View File

@ -574,6 +574,7 @@ impl Host for crate::PdfCraftApp {
"files": self.combine_draft.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
"selected": self.combine_selection(),
"thumbnails": self.combine_thumbnails().iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
"expanded": self.combine_expanded().iter().filter_map(|i| self.combine_draft.get(*i).map(|f| f.name.clone())).collect::<Vec<_>>(),
"preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })),
},
"quick_tool": match self.quick_tool {

View File

@ -2185,3 +2185,5 @@ key Space مسافة
Pages: {k} of {n} الصفحات: {k} من {n}
Check the pages تحقق من الصفحات
Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها.
Expand all توسيع الكل
Collapse all طي الكل

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2125,3 +2125,5 @@ key Space Интервал
Pages: {k} of {n} Страници: {k} от {n}
Check the pages Проверете страниците
Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат.
Expand all Разгъни всички
Collapse all Свий всички

Can't render this file because it contains an unexpected character in line 8 and column 16.

View File

@ -2163,3 +2163,5 @@ key Space Leertaste
Pages: {k} of {n} Seiten: {k} von {n}
Check the pages Prüfe die Seiten
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

Can't render this file because it contains an unexpected character in line 6 and column 51.

View File

@ -2079,3 +2079,5 @@ key Space Espacio
Pages: {k} of {n} Páginas: {k} de {n}
Check the pages Revisa las páginas
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

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -2163,3 +2163,5 @@ key Space Espace
Pages: {k} of {n} Pages : {k} sur {n}
Check the pages Vérifiez les pages
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

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -2161,3 +2161,5 @@ key Space Szóköz
Pages: {k} of {n} Oldalak: {k} / {n}
Check the pages Ellenőrizd az oldalakat
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

Can't render this file because it contains an unexpected character in line 7 and column 44.

View File

@ -2172,3 +2172,5 @@ key Space Spazio
Pages: {k} of {n} Pagine: {k} di {n}
Check the pages Controlla le pagine
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

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2163,3 +2163,5 @@ key Space スペース
Pages: {k} of {n} ページ: {k}/{n}
Check the pages ページ指定を確認してください
Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。
Expand all すべて展開
Collapse all すべて折りたたむ

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2164,3 +2164,5 @@ key Space Espaço
Pages: {k} of {n} Páginas: {k} de {n}
Check the pages Verifique as páginas
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

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -2076,3 +2076,5 @@ key Space Пробел
Pages: {k} of {n} Страницы: {k} из {n}
Check the pages Проверьте страницы
Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять.
Expand all Развернуть все
Collapse all Свернуть все

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -2094,3 +2094,5 @@ key Space స్పేస్
Larger pages పెద్ద పేజీలు
Smaller pages చిన్న పేజీలు
Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి
Expand all అన్నీ విస్తరించు
Collapse all అన్నీ ముడుచు

Can't render this file because it contains an unexpected character in line 13 and column 16.

View File

@ -2186,3 +2186,5 @@ key Space Пробіл
Pages: {k} of {n} Сторінки: {k} із {n}
Check the pages Перевірте сторінки
Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти.
Expand all Розгорнути всі
Collapse all Згорнути всі

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2127,3 +2127,5 @@ key Space 空格
Pages: {k} of {n} 页面:{k}/{n}
Check the pages 请检查页码
Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。
Expand all 全部展开
Collapse all 全部折叠

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -2049,3 +2049,5 @@ key Space 空格
Pages: {k} of {n} 頁面:{k}/{n}
Check the pages 請檢查頁碼
Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。
Expand all 全部展開
Collapse all 全部摺疊

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -116,6 +116,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")),
("mic", include_bytes!("../../../assets/icons/mic.svg")),
("minimize", include_bytes!("../../../assets/icons/minimize.svg")),
("minimize-2", include_bytes!("../../../assets/icons/minimize-2.svg")),
("minus", include_bytes!("../../../assets/icons/minus.svg")),
("moon", include_bytes!("../../../assets/icons/moon.svg")),
("mouse-pointer-2", include_bytes!("../../../assets/icons/mouse-pointer-2.svg")),

View File

@ -1287,3 +1287,283 @@ fn a_drag_held_while_the_tab_closes_and_reopens_moves_nothing() {
h.run_steps(3);
assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"], "the old drag moves nothing in the new list");
}
// ---- Stage 3: a file shown as its pages ----
/// The label of a page card: "Page 3 · b.pdf".
fn page_card(page: usize, name: &str) -> String {
format!("Page {page} · {name}")
}
/// Expand a file from its card's hover bar.
fn expand(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
hover_card(h, name);
h.get_by_label("Expand").click();
h.run_steps(2);
}
#[test]
fn expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers() {
let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]);
h.state_mut().combine_draft[1].range = "3, 1, 4".into();
h.run_steps(2);
// A single page has nothing to expand.
hover_card(&mut h, "a.pdf");
assert!(h.query_by_label("Expand").is_none());
expand(&mut h, "b.pdf");
assert_eq!(h.state().combine_expanded(), [1]);
assert!(h.query_by_label("b.pdf").is_none(), "the file's card gives way to its pages");
// The pages it adds, in the range's order, each saying which page of which file.
let xs: Vec<f32> = [3, 1, 4].iter().map(|p| card(&h, &page_card(*p, "b.pdf")).left()).collect();
assert!(xs.windows(2).all(|w| w[0] < w[1]), "pages 3, 1, 4 in that order: {xs:?}");
assert!(h.query_by_label(&page_card(2, "b.pdf")).is_none(), "page 2 isn't added, so isn't shown");
// Each renders its own page.
assert!(settle(&mut h, |app| {
let pages = app.combine_page_thumbnails(1);
pages.len() == 3 && pages.iter().all(|(p, t)| matches!(t, CombineThumb::Ready { page, .. } if page == p))
}));
// A page card collapses the file again.
hover_card(&mut h, &page_card(1, "b.pdf"));
h.get_by_label("Collapse").click();
h.run_steps(2);
assert!(h.state().combine_expanded().is_empty());
h.get_by_label("b.pdf");
}
#[test]
fn expand_all_and_collapse_all_from_the_toolbar_and_a_right_click() {
let mut h = grid_of(
vec![("a.pdf", fixture(1)), ("b.pdf", fixture(3)), ("c.pdf", fixture(2)), ("secret.pdf", protected("pw", "owner"))],
vec2(1400.0, 900.0),
);
assert!(disabled(&h, "Collapse all"), "nothing to collapse yet");
h.get_by_label("Expand all").click();
h.run_steps(2);
assert_eq!(h.state().combine_expanded(), [1, 2], "every file adding more than one page; not a single page, not a locked file");
assert!(disabled(&h, "Expand all"));
h.get_by_label("Collapse all").click();
h.run_steps(2);
assert!(h.state().combine_expanded().is_empty());
// On a right click too (the pointer then off the card, so its hover bar is gone and the
// label is the menu's alone).
h.get_by_label("c.pdf").click_secondary();
h.run_steps(2);
h.hover_at(egui::pos2(5.0, 890.0));
h.run_steps(2);
h.get_by_label("Expand").click();
h.run_steps(2);
assert_eq!(h.state().combine_expanded(), [2]);
h.get_by_label(&page_card(2, "c.pdf")).click_secondary();
h.run_steps(2);
h.hover_at(egui::pos2(5.0, 890.0));
h.run_steps(2);
h.get_by_label("Collapse").click();
h.run_steps(2);
assert!(h.state().combine_expanded().is_empty());
}
#[test]
fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() {
let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 3)]);
let undo = disabled(&h, "Undo");
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<String> = 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();
h.run_steps(2);
h.get_by_label("Larger pages").click();
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).
h.state_mut().combine_draft[1].range = "2".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"));
h.state_mut().combine_draft[1].range = String::new();
h.run_steps(2);
h.get_by_label("b.pdf");
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();
h.run_steps(1);
h.key_press(Key::Delete);
h.run_steps(2);
assert_eq!(names(&h), ["a.pdf"]);
assert!(h.state().combine_expanded().is_empty());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(2);
assert_eq!(names(&h), ["a.pdf", "b.pdf"]);
h.get_by_label("b.pdf");
}
#[test]
fn a_page_selects_its_file_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))];
files.extend((0..8).map(|i| (format!("f{i}.pdf"), fixture(1))));
let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1000.0, 1100.0));
expand(&mut h, "b.pdf");
let top = card(&h, "a.pdf").top();
let cols = ["a.pdf".to_string(), page_card(1, "b.pdf"), page_card(2, "b.pdf"), page_card(3, "b.pdf"), page_card(4, "b.pdf")]
.iter()
.filter(|n| (card(&h, n).top() - top).abs() < 1.0)
.count();
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");
// 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);
let name = names(&h)[below[0]].clone();
assert!(below[0] > 1, "a file after b: {name}");
let r = card(&h, &name);
assert!((r.left() - last.left()).abs() < 1.0 && r.top() > last.top(), "the card right below b's last page: {r:?} vs {last:?}");
h.key_press(Key::ArrowUp);
h.run_steps(2);
assert_eq!(h.state().combine_selection(), [1]);
h.key_press(Key::ArrowLeft);
h.run_steps(2);
assert_eq!(h.state().combine_selection(), [0], "left and right step by file");
}
#[test]
fn dragging_a_page_moves_its_whole_file_and_drops_go_to_a_file_edge() {
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.
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");
}
#[test]
fn a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads() {
let mut h = grid_of(vec![("long.pdf", fixture(300)), ("b.pdf", fixture(1))], vec2(1400.0, 900.0));
h.get_by_label("Expand all").click();
let mut most = 0;
for _ in 0..200 {
h.run_steps(1);
let threads = h.state_mut().combine_thumbnail_threads();
most = most.max(threads);
assert!(threads <= 3, "{threads} render threads");
if h.state().combine_thumbnails_rendering() == 0 && h.state().combine_page_thumbnails(0).first().is_some_and(|(_, t)| ready(t)) {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
let pages = h.state().combine_page_thumbnails(0);
assert_eq!(pages.len(), 300);
let drawn = pages.iter().filter(|(_, t)| ready(t)).count();
assert!(drawn > 0 && drawn < 60, "only the pages in view (and a row either side): {drawn} of 300");
assert!(h.query_by_label(&page_card(300, "long.pdf")).is_none(), "the last page, far out of view, isn't even laid out as a widget");
}
#[test]
fn a_page_cards_magnifier_opens_the_preview_on_that_page() {
let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 5)]);
// Pages 3, 1, 4: page 4 is the third the file adds.
h.state_mut().combine_draft[1].range = "3, 1, 4".into();
h.run_steps(2);
expand(&mut h, "b.pdf");
hover_card(&mut h, &page_card(4, "b.pdf"));
h.get_by_label("Preview").click();
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.
h.key_press(Key::Escape);
h.run_steps(2);
hover_card(&mut h, &page_card(4, "b.pdf"));
h.get_by_label("Collapse");
assert!(h.query_by_label("Remove b.pdf").is_none());
// Space on a page card that has the keyboard: that page too.
h.get_by_label(&page_card(1, "b.pdf")).click();
h.run_steps(2);
h.get_by_label(&page_card(1, "b.pdf")).focus();
h.run_steps(2);
h.key_press(Key::Space);
h.run_steps(2);
assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds");
h.get_by_label("Page 2 of 3");
}
#[test]
fn combining_with_files_expanded_gives_the_same_document() {
let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 3)]);
h.state_mut().combine_draft[1].range = "3, 1".into();
h.get_by_label("Expand all").click();
h.run_steps(2);
assert_eq!(h.state().combine_expanded(), [0, 1]);
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 2", "Page 3", "Page 1"]);
}
#[test]
fn collapsing_brings_the_files_card_into_view() {
// Many files after it, so the grid stays long once it collapses (it would otherwise scroll
// back by itself).
let mut files = vec![("long.pdf".to_string(), fixture(60))];
files.extend((0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))));
let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0));
expand(&mut h, "long.pdf");
// Down to its last pages.
h.hover_at(egui::pos2(900.0, 600.0));
for _ in 0..80 {
if h.query_by_label(&page_card(60, "long.pdf")).is_some_and(|c| c.rect().top() > 300.0 && c.rect().bottom() < 880.0) {
break;
}
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -120.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
});
h.run_steps(3);
}
hover_card(&mut h, &page_card(60, "long.pdf"));
h.get_by_label("Collapse").click();
for _ in 0..20 {
h.run_steps(1);
}
let r = card(&h, "long.pdf");
let toolbar = h.get_by_label("Grid view").rect().bottom();
assert!(r.bottom() > toolbar && r.top() < 900.0, "the file's card is in view: {r:?}");
}
#[test]
fn a_file_too_long_to_spread_out_offers_no_expand() {
// 2001 pages: more cards than are laid out comfortably every frame.
let mut h = grid_of(vec![("huge.pdf", fixture(2001)), ("b.pdf", fixture(3))], vec2(1400.0, 900.0));
hover_card(&mut h, "huge.pdf");
assert!(h.query_by_label("Expand").is_none());
h.get_by_label("Expand all").click();
h.run_steps(2);
assert_eq!(h.state().combine_expanded(), [1], "only the file that can be");
}

View File

@ -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"]
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. Missing: expanding a file into its pages, and 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_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)."
[[feature]]
id = "organize.combine-options"