mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
Merge main into smooth-scrolling-preference
Conflicts: - ATTRIBUTION.toml: main changed the 20 translation catalogs again; they merged cleanly, and their hashes are recomputed from the merged files. - ROADMAP.md: both sides added a revision row at the same place; both kept. - contributors/people.toml: two people each added their own entry at the end of the file; both kept exactly as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+20
-20
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "62be95c3f81c7f6bfd07d0c4a0e30110d8fda66bf80ee88a7d544d5950be1c9f"
|
||||
sha256 = "657bc9a96e997acb2e896d22abd366a897e3382f27fc7c410a1ef5bdc69104d0"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -30,7 +30,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 = "cdc4ff011234a893e130d15c1cbaa655a8fb4e37aa3dbc920f289464a80bf180"
|
||||
sha256 = "8488be5cb07151c4c310b766b8bb76672f92971e3e54219accc1f2f8bed35c83"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,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 = "eada5d4f4a59a21b37a221d2112faccef1178bfb8b28ecb3e4bead1b12e95158"
|
||||
sha256 = "0370dee8a3b4c24097747edf01ca091fb2914166f0151e1e23d105d73dfaf161"
|
||||
|
||||
[[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 = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "7933ef0b4fd90b3fcefa68137649aa6ea63536f7aa4c560d5a9106383f098b4e"
|
||||
sha256 = "41c2138cdd04c39d6d661e6db70774e0223746130c6536e3556c8f06fa76f731"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,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 = "6bff8eeea85964a033d8136529cc745948edc7b7409487ea903a025e9b1026e1"
|
||||
sha256 = "676bd5fe27d2f8a1b1aac4ca2153207fae4a0a4f279f66beb3a648973af3d02a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,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 = "5f1f531f21c9a7801b4b0d1f7667aea859259643a988bd87adb631258469ac3f"
|
||||
sha256 = "935ad1e5a12d67f52c8007022faf54d61928e70bac1b184ec623f0641af3d2eb"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,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 = "0387fce774322acc0f5e4ba3e6f54dfad739b58769e80480114df8062d0abe25"
|
||||
sha256 = "2b88514e089f08bf302411e418bc31e3445fd7772caf6c9cbdd22d830c16886e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,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 = "42c3eb698c294c78bc95dd587fc91bbe552c5906d1d4127df8f4c2acfabcb65c"
|
||||
sha256 = "e7b755aae3c0f45e9c936f37220af290e133bd0bb6d1e106fd349b3517cd0247"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,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 = "1a5de1ec07a084293974deac32fb7561d1ce57838bbed6e52c0b8d0a0f4b810f"
|
||||
sha256 = "b789db385ccede5774382beef49d2fc6a75a76082d21276855eb3a0a6c6837ed"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,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 = "7fde9ff7750f7aa17b10a0c4fa687d63829058dcd38b24b40a8948fb58380ace"
|
||||
sha256 = "05b10895c062df8a2f6e42cab801fcaaa1f0e793d7b62d9b5ea9eed8baf8e914"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "238b61d4bf0f48a1b3a1bad56282262c5b9eac0c3f4d998f64e5a25c1496c3dd"
|
||||
sha256 = "478e0b058359f1f213721657157a2c6f6c9759e98a1bd30f0852f06571a83e8d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,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 = "0ea8cac16eac4c9e82c57bcc355c45b33d7e7a546e14f3c869ea98f14f38cab7"
|
||||
sha256 = "fb815a01750c0d870a953cd9d450664b223266e3d2bdd056b2ffe03d3b0d2917"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,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 = "c2911ce65db390749ea9989372372c6cc6c4787c949f877bf758ebe2a49c787c"
|
||||
sha256 = "0a46c705ed0a00731b2834b1e5669eba9be8944d1db5f341d1efc6a6ff7efee1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,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 = "98d27dc2857a2217915d2f04bd2dd408b883ab332ec390af146dfef739b8754e"
|
||||
sha256 = "ba5bffa22a11ab087db9777b7710895ec75213d2efd284f73b8e14d277d209ab"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "35409f287cde3eca2b20848ecfec5cc274b9a33b1747789da7d97c12a45512da"
|
||||
sha256 = "2b6d916eb54c26aa439864e718eeea4cf2eab29f44d1ba5aab2ed3c92d34d1d3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,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 = "e0347f5651bc6c26a67a16fe5c1f5d38e8cfd27e4105e1e8917d20d6d6110ce7"
|
||||
sha256 = "6d6a974b3ed8ebfbf5db9967bd26964aa3a02ad34f2e16be7facffbb2129cd34"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,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 = "2a614e98f15eaa10686e1925ac65502b143b2f9fce8c72f2e5c2d893eef37e96"
|
||||
sha256 = "013f483cc43314ab8aa6aac5a2f922b6d62f05b79d386162b9e2179783a12914"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,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 = "219de06dfa51d3061c306b4ea3927bd56f61fc94742a3d8627da22acd0347dae"
|
||||
sha256 = "5c7a877cc61db1f706d8c83fb0269e99a9d243dc8a8648fd7407c4fae4ffde99"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "6a2b64db37b119d1f54a828b427b2fb7a2b8c271e0e8afbab5f6080d4d8027bc"
|
||||
sha256 = "26309a1de18df3873662b8d7c5d63b6b1f43cfa4a3c43a2fe0d01637debe5a6a"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3339,4 +3339,4 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c8952a411a59ad292d2e5ca551e1f0d519a4f1434d664e39a5dd03718b2382d8"
|
||||
sha256 = "eddbea7c050c06c628ec1bb64eb564013f378e798ebfe62c331b0604402f256e"
|
||||
|
||||
Generated
+1
@@ -3617,6 +3617,7 @@ name = "pdfcraft-automation"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
|
||||
@@ -154,6 +154,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
|
||||
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
|
||||
- **2026-10-11 (M3, smooth scrolling and Windows' Animation effects setting):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has Smooth scrolling: Use system setting (the default), On or Off. With Animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. Smooth scrolling set to On or Off decides the wheel's scrolling and zooming, and scrolling to a place, whatever the system says (menus, sections and the progress card always follow the system); it is saved, set as `smooth_scrolling` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting eases there as before; Off works everywhere. Estimates unchanged.
|
||||
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
|
||||
@@ -173,6 +174,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged.
|
||||
- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged.
|
||||
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
|
||||
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
|
||||
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
|
||||
@@ -375,6 +378,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
|
||||
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed (wheel scrolling and zooming without easing, egui's animations off), and Preferences has Smooth scrolling (Use system setting, On, Off). Estimates unchanged. |
|
||||
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
|
||||
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
|
||||
|
||||
@@ -116,6 +116,9 @@ real_name = "Ufuk Efe"
|
||||
display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
[people.JeremyYao]
|
||||
real_name = "Jeremy Yao"
|
||||
display_name = "Jeremy Yao"
|
||||
|
||||
@@ -27,6 +27,7 @@ mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
flate2.workspace = true
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
@@ -469,13 +469,34 @@ impl Automation {
|
||||
let at = to_user(&info, x, y);
|
||||
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
|
||||
let kind = a.str("type")?;
|
||||
let scan = a.opt_bool("scan")?.unwrap_or(false);
|
||||
let sensitivity = a.opt_int("sensitivity")?;
|
||||
if (scan || sensitivity.is_some()) && a.opt_str("path")?.is_none() {
|
||||
return Err(ToolError::InvalidArgs("scan and sensitivity need path, a photo of a signature on paper".into()));
|
||||
}
|
||||
if sensitivity.is_some() && !scan {
|
||||
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
|
||||
}
|
||||
if let Some(path) = a.opt_str("path")? {
|
||||
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
|
||||
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
|
||||
}
|
||||
let path = self.resolve(path, false)?;
|
||||
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
|
||||
let image = if scan {
|
||||
let sensitivity = match sensitivity {
|
||||
None => pdfcraft_engine::signature_scan::DEFAULT_SENSITIVITY,
|
||||
Some(s) => u8::try_from(s)
|
||||
.ok()
|
||||
.filter(|s| *s <= 100)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("sensitivity must be 0–100, got {s}")))?,
|
||||
};
|
||||
let mut photo = pdfcraft_engine::SignaturePhoto::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
|
||||
let frame = photo.auto_frame();
|
||||
photo.scan(frame, sensitivity, pdfcraft_engine::signature_scan::SCAN_MAX_SIDE).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
|
||||
} else {
|
||||
pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?
|
||||
};
|
||||
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
|
||||
return self.apply(a, edit);
|
||||
}
|
||||
|
||||
@@ -1361,7 +1361,7 @@ impl Automation {
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at })
|
||||
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
|
||||
}
|
||||
|
||||
/// Write freshly created bytes to `out` and/or open them as a new document.
|
||||
@@ -1498,6 +1498,13 @@ impl Automation {
|
||||
}
|
||||
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
|
||||
};
|
||||
let field_conflicts = match a.opt_str("field_conflicts")? {
|
||||
None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename,
|
||||
Some("merge") => pdfcraft_organize::FieldConflicts::Merge,
|
||||
Some(other) => {
|
||||
return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}")));
|
||||
}
|
||||
};
|
||||
let mut sources: Vec<CombineSource> = Vec::new();
|
||||
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
|
||||
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
|
||||
@@ -1525,8 +1532,8 @@ impl Automation {
|
||||
sources.push((name, bytes, range));
|
||||
}
|
||||
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
|
||||
let bytes = match groups {
|
||||
None => self.session.combine_unlocked(&sources, &passwords),
|
||||
let combined = match groups {
|
||||
None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts),
|
||||
Some(groups) => {
|
||||
// A path without a group is a file of its own: give it a key no group uses.
|
||||
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
|
||||
@@ -1541,11 +1548,15 @@ impl Automation {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.session.combine_grouped(&sources, &keys, &passwords)
|
||||
self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts)
|
||||
}
|
||||
}
|
||||
.map_err(failed)?;
|
||||
self.deliver(a, "Combined", bytes)
|
||||
let mut result = self.deliver(a, "Combined", combined.bytes)?;
|
||||
if !combined.renamed_fields.is_empty() {
|
||||
result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn doc_split(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -221,7 +221,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
|
||||
&["doc", "pages"],
|
||||
)),
|
||||
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.")
|
||||
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
|
||||
.cmd("page.combine")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -229,6 +229,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
|
||||
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
|
||||
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
|
||||
"field_conflicts": { "type": "string", "enum": ["rename", "merge"] },
|
||||
"out": save_out,
|
||||
"open": open,
|
||||
}),
|
||||
@@ -920,7 +921,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
|
||||
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
@@ -1019,7 +1020,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"fill_sign_add",
|
||||
"Fill & Sign: type text or place a mark",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
)
|
||||
.cmd("sign.fill.text")
|
||||
.with(schema(
|
||||
@@ -1030,6 +1031,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"at": point(),
|
||||
"text": { "type": "string", "minLength": 1 },
|
||||
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
|
||||
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
|
||||
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
|
||||
"format": { "type": "string", "description": "For type date: this pattern instead of the date-format preference, e.g. \"dd.mm.yyyy\"." },
|
||||
"language": { "type": "string", "description": "For type date: month and weekday names in this language code (see fill_sign_date_format) instead of the preference." },
|
||||
"author": { "type": "string" },
|
||||
|
||||
@@ -49,6 +49,29 @@ fn auto(dir: &Path) -> Automation {
|
||||
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
|
||||
}
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name`.
|
||||
fn form_pdf(name: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
@@ -240,6 +263,23 @@ fn edit_undo_redo_and_save_round_trip() {
|
||||
assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2);
|
||||
}
|
||||
|
||||
/// organize.field-name-conflicts: combining files whose forms share a field name keeps the
|
||||
/// fields separate by renaming, says so in the result, and can be told to merge instead; any
|
||||
/// other policy is an argument error.
|
||||
#[test]
|
||||
fn combine_renames_colliding_field_names() {
|
||||
let dir = workdir("combine-field-conflicts");
|
||||
std::fs::write(dir.join("form-a.pdf"), form_pdf("Text1")).unwrap();
|
||||
std::fs::write(dir.join("form-b.pdf"), form_pdf("Text1")).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "open": true }));
|
||||
assert_eq!(combined["renamed_fields"], json!([["Text1", "Text1-1"]]), "{combined}");
|
||||
let merged = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "merge" }));
|
||||
assert!(merged.get("renamed_fields").is_none(), "merge renames nothing: {merged}");
|
||||
let bad = a.call("doc_combine", &json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "bogus" }));
|
||||
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// #569: images and text files combine with PDFs, each made into a PDF first.
|
||||
#[test]
|
||||
fn combine_takes_images_and_text_files() {
|
||||
@@ -2091,6 +2131,68 @@ fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments() {
|
||||
let dir = workdir("scanned-signatures");
|
||||
// A grey, unevenly lit sheet with a pen stroke for the signature, and a blank one.
|
||||
let photo = |ink: bool| {
|
||||
let mut png = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut png, 400, 300);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let rgb: Vec<u8> = (0..300_u32)
|
||||
.flat_map(|y| {
|
||||
(0..400_u32).flat_map(move |x| {
|
||||
let paper = (215 - x / 20) as u8;
|
||||
if ink && (100..300).contains(&x) && (144..148).contains(&y) { [20, 20, 25] } else { [paper, paper, paper] }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
encoder.write_header().unwrap().write_image_data(&rgb).unwrap();
|
||||
png
|
||||
};
|
||||
std::fs::write(dir.join("photo.png"), photo(true)).unwrap();
|
||||
std::fs::write(dir.join("blank.png"), photo(false)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 60], "path": "photo.png", "scan": true }));
|
||||
let output = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||||
let page = image::load_from_memory(data).unwrap().to_rgba8();
|
||||
let dark = (20..170).filter(|&x| page.get_pixel(x, 60).0[0] < 80).count();
|
||||
assert!(dark > 100, "the stroke is placed as ink across the signature width ({dark} px)");
|
||||
for (x, y) in [(22, 50), (100, 50), (100, 70)] {
|
||||
assert_eq!(page.get_pixel(x, y).0, [255, 255, 255, 255], "the grey paper is gone at {x},{y}");
|
||||
}
|
||||
ok(
|
||||
&mut a,
|
||||
"fill_sign_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "initials", "at": [20, 150], "path": "photo.png", "scan": true, "sensitivity": 0 }),
|
||||
);
|
||||
for args in [
|
||||
json!({ "type": "signature", "path": "blank.png", "scan": true }),
|
||||
json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": 101 }),
|
||||
json!({ "type": "signature", "path": "photo.png", "scan": true, "sensitivity": -1 }),
|
||||
json!({ "type": "signature", "path": "photo.png", "sensitivity": 50 }),
|
||||
json!({ "type": "signature", "text": "Ada", "scan": true }),
|
||||
json!({ "type": "signature", "path": "photo.png", "scan": "yes" }),
|
||||
] {
|
||||
let mut args = args;
|
||||
args["doc"] = json!(doc);
|
||||
args["page"] = json!(1);
|
||||
args["at"] = json!([20, 80]);
|
||||
let error = a.call("fill_sign_add", &args).unwrap_err();
|
||||
assert!(matches!(error, ToolError::InvalidArgs(_)), "{args}: {error:?}");
|
||||
}
|
||||
let Err(ToolError::InvalidArgs(no_ink)) =
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 80], "path": "blank.png", "scan": true }))
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(no_ink.contains("No signature was found"), "{no_ink}");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 2, "errors leave the PDF intact");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
|
||||
let dir = workdir("signature-preview");
|
||||
@@ -3713,6 +3815,49 @@ fn exporting_to_word_html_and_rtf() {
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||||
}
|
||||
|
||||
/// #773: a diagonal watermark must not enter the body flow or move invoice fields.
|
||||
#[test]
|
||||
fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() {
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, SaveOptions, Stream, write_full};
|
||||
let dir = workdir("invoice-word");
|
||||
let mut pdf = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let content = b"q BT /F1 60 Tf 0.8 g 0.70710678 0.70710678 -0.70710678 0.70710678 30 80 Tm (DRAFT) Tj ET Q \
|
||||
BT /F1 11 Tf 20 250 Td (Description) Tj 110 0 Td (Amount) Tj \
|
||||
-110 -20 Td (Design) Tj 110 0 Td (100.00) Tj \
|
||||
-110 -20 Td (Review) Tj 110 0 Td (50.00) Tj ET \
|
||||
BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
pdf.set(ObjRef::new(5, 0), Stream { dict: Dict::new(), raw: content.to_vec().into() });
|
||||
std::fs::write(dir.join("invoice.pdf"), write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "invoice.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "invoice.docx" }));
|
||||
// Inspect the actual package emitted by the tool, not an intermediate page model.
|
||||
let zip = std::fs::read(dir.join("invoice.docx")).unwrap();
|
||||
let mut at = 0;
|
||||
let mut xml = String::new();
|
||||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||||
let data = at + 30 + len;
|
||||
if &zip[at + 30..data] == b"word/document.xml" {
|
||||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||||
break;
|
||||
}
|
||||
at = data + size;
|
||||
}
|
||||
assert!(xml.contains("rotation:315.000"), "diagonal text survives extraction: {xml}");
|
||||
assert!(xml.contains("z-index:-2"), "the large watermark stays behind the invoice");
|
||||
assert!(!xml.contains("<wp:inline>") && !xml.contains("<w:tbl>"));
|
||||
assert!(!xml.contains("w:val=\"single\""));
|
||||
assert_eq!(xml.matches(">DRAFT</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches(">Total due: 150.00</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches("<w:sectPr>").count(), 1);
|
||||
assert!(!xml.contains("w:type=\"page\""));
|
||||
assert!(xml.contains("margin-left:20.000pt"), "invoice fields retain their left edge");
|
||||
let rendered = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_files_export_and_import_through_tools() {
|
||||
let dir = workdir("xfa-data");
|
||||
|
||||
@@ -236,6 +236,8 @@ pub struct TextBlock {
|
||||
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
|
||||
/// override is requested.
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in page user space, in degrees.
|
||||
pub rotation: f64,
|
||||
/// Indexes into [`text_lines`].
|
||||
pub lines: Vec<usize>,
|
||||
/// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs.
|
||||
@@ -301,6 +303,11 @@ impl TextLine {
|
||||
pub fn paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
fn rotation(&self) -> f64 {
|
||||
let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0;
|
||||
m[1].atan2(m[0]).to_degrees()
|
||||
}
|
||||
}
|
||||
|
||||
/// What replacing a line did.
|
||||
|
||||
@@ -41,6 +41,7 @@ fn same_style(a: &TextLine, b: &TextLine) -> bool {
|
||||
&& a.bold == b.bold
|
||||
&& a.italic == b.italic
|
||||
&& a.color == b.color
|
||||
&& (a.rotation() - b.rotation()).abs() < 0.01
|
||||
&& (a.size - b.size).abs() < 0.01
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
bold: first.bold,
|
||||
italic: first.italic,
|
||||
color: first.color,
|
||||
rotation: first.rotation(),
|
||||
lines: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
items: &[
|
||||
item("Single file", "file-input", "create.file", Ready),
|
||||
item("Multiple files", "files", "create.multiple", Ready),
|
||||
item("Separate PDFs", "files", "create.multiple_separate", Ready),
|
||||
item("Images", "image", "create.images", Ready),
|
||||
item("Clipboard", "copy-plus", "create.clipboard", Ready),
|
||||
item("Blank page", "file-plus-2", "create.blank", Ready),
|
||||
|
||||
@@ -179,6 +179,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
|
||||
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
|
||||
c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"),
|
||||
c("create.multiple_separate", "Create separate PDFs…", FILE, None, Nothing, "files"),
|
||||
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
|
||||
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
|
||||
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
|
||||
|
||||
@@ -158,6 +158,16 @@ impl crate::Document {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, info)| {
|
||||
// Export uses the visible page in points, y up. Raw PDF coordinates include
|
||||
// CropBox offsets and precede /Rotate and /UserUnit.
|
||||
let visible_rect = |r: [f64; 4]| {
|
||||
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
||||
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
||||
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
|
||||
};
|
||||
let crop_width = f64::from(info.crop[2] - info.crop[0]);
|
||||
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
|
||||
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
|
||||
let blocks = pdfcraft_edit::reading_blocks(cos, i)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -166,11 +176,12 @@ impl crate::Document {
|
||||
let f = b.base_font.to_ascii_lowercase();
|
||||
pdfcraft_export::Block {
|
||||
text: b.text,
|
||||
rect: b.rect,
|
||||
size: b.size,
|
||||
rect: visible_rect(b.rect),
|
||||
size: b.size * unit,
|
||||
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
|
||||
italic: f.contains("italic") || f.contains("oblique"),
|
||||
color: b.color,
|
||||
rotation: b.rotation - f64::from(info.rotation),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -179,7 +190,13 @@ impl crate::Document {
|
||||
.iter()
|
||||
.filter_map(|im| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
||||
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
|
||||
Some(pdfcraft_export::Image {
|
||||
ext: if ext == "jpg" { "jpg" } else { "png" },
|
||||
bytes,
|
||||
rect: visible_rect(im.rect),
|
||||
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
|
||||
rotation: im.matrix[1].atan2(im.matrix[0]).to_degrees() - f64::from(info.rotation),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
|
||||
|
||||
@@ -26,15 +26,17 @@ pub mod links;
|
||||
pub mod ocr;
|
||||
pub mod optimizer;
|
||||
pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
mod upright_image;
|
||||
pub mod xfa;
|
||||
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use text_preview::TextEditPreview;
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges};
|
||||
|
||||
/// One file produced by a split: (1-based first page, last page, PDF bytes).
|
||||
pub use pdfcraft_organize::LabelStyle;
|
||||
@@ -748,6 +750,14 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes:
|
||||
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
|
||||
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
|
||||
|
||||
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
|
||||
/// separate (old fully-qualified name → new one), in order across the sources.
|
||||
#[derive(Debug)]
|
||||
pub struct Combined {
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A bookmark as the bookmark list shows it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BookmarkRow {
|
||||
@@ -999,6 +1009,8 @@ pub enum Edit {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
pages: Option<Vec<usize>>,
|
||||
at: usize,
|
||||
/// How a form field whose name the document already uses is kept separate.
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts,
|
||||
},
|
||||
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
|
||||
AddBookmark {
|
||||
@@ -1477,6 +1489,8 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Change Keywords",
|
||||
"Change {key}",
|
||||
"Insert pages from {name}",
|
||||
"Insert pages from {name} — 1 field renamed",
|
||||
"Insert pages from {name} — fields renamed",
|
||||
"Add bookmark",
|
||||
"Rename bookmark",
|
||||
"Delete bookmark",
|
||||
@@ -1888,6 +1902,9 @@ struct EditCtx {
|
||||
today: (i64, u32, u32),
|
||||
seed: u64,
|
||||
count: u64,
|
||||
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
|
||||
/// log and the undo label.
|
||||
renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl EditCtx {
|
||||
@@ -1912,6 +1929,7 @@ impl EditCtx {
|
||||
today: (1970, 1, 1),
|
||||
seed,
|
||||
count: 0,
|
||||
renamed_fields: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1968,13 +1986,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
|
||||
}
|
||||
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
|
||||
Edit::InsertPagesFrom { name, bytes, pages, at } => {
|
||||
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
|
||||
let src = open_source(name, bytes)?;
|
||||
let pages = match pages {
|
||||
Some(p) => p.clone(),
|
||||
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
|
||||
};
|
||||
pdfcraft_organize::import_pages(doc, &src, &pages, *at)?;
|
||||
let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?;
|
||||
cx.renamed_fields = outcome.renamed_fields;
|
||||
}
|
||||
Edit::AddBookmark { parent, index, title, page } => {
|
||||
pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
|
||||
@@ -3080,9 +3099,38 @@ impl Session {
|
||||
if !js_off && uses_scripts(&edit) {
|
||||
cx.js = Some(js::JsRunner::new(&next, &name));
|
||||
}
|
||||
cx.renamed_fields.clear();
|
||||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
|
||||
// scripts were left alone) and a count in the undo step's label.
|
||||
let renamed_note = |renamed: &[(String, String)]| {
|
||||
(!renamed.is_empty()).then(|| {
|
||||
let shown: Vec<String> = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect();
|
||||
let more = renamed.len().saturating_sub(shown.len());
|
||||
let more = if more > 0 { format!(", and {more} more") } else { String::new() };
|
||||
format!(
|
||||
"inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten",
|
||||
renamed.len(),
|
||||
if renamed.len() == 1 { "" } else { "s" },
|
||||
shown.join(", ")
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(note) = renamed_note(&cx.renamed_fields) {
|
||||
next.note_repair(note);
|
||||
}
|
||||
// Only Insert from File renames fields; its label says so in a wording the interface
|
||||
// translates (the count lives in the repair note).
|
||||
let label = match (&edit, cx.renamed_fields.len()) {
|
||||
(_, 0) => edit.label(),
|
||||
(Edit::InsertPagesFrom { name, .. }, n) => Label::with(
|
||||
if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" },
|
||||
name.as_str(),
|
||||
),
|
||||
_ => edit.label(),
|
||||
};
|
||||
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
|
||||
// every calculation (and a reset recalculates). They read the data, so it is brought up
|
||||
// to date first.
|
||||
@@ -3143,7 +3191,7 @@ impl Session {
|
||||
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
|
||||
let previous_keys = std::mem::replace(&mut editor.keys, keys);
|
||||
let scope = scope_of(&edit);
|
||||
editor.undo.push((edit.label(), previous, previous_keys, scope));
|
||||
editor.undo.push((label, previous, previous_keys, scope));
|
||||
if editor.undo.len() > MAX_UNDO {
|
||||
editor.undo.remove(0);
|
||||
}
|
||||
@@ -3695,6 +3743,28 @@ impl Session {
|
||||
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
|
||||
/// position (missing or `None`: no password). The result is not encrypted.
|
||||
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
|
||||
}
|
||||
|
||||
/// The pre-flight over byte sources: what combining them, in order, would rename in each one
|
||||
/// (whole files; page ranges are the combine's to apply). A source that cannot be opened
|
||||
/// previews as nothing — its row already says why it cannot be combined.
|
||||
pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec<Vec<(String, String)>> {
|
||||
let docs: Vec<Option<pdfcraft_cos::Document>> =
|
||||
sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect();
|
||||
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect();
|
||||
let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter();
|
||||
// One preview per source, in step with it: a file that cannot be opened has none.
|
||||
docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect()
|
||||
}
|
||||
|
||||
/// [`Self::combine_unlocked`] reporting what the conflict resolution renamed.
|
||||
pub fn combine_unlocked_reporting(
|
||||
&self,
|
||||
sources: &[CombineSource],
|
||||
passwords: &[Option<&str>],
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts,
|
||||
) -> Result<Combined, EditError> {
|
||||
let docs = sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -3715,8 +3785,9 @@ impl Session {
|
||||
}
|
||||
let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> =
|
||||
sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect();
|
||||
let out = pdfcraft_organize::combine_selected(&named)?;
|
||||
self.write_new(&out)
|
||||
let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?;
|
||||
let bytes = self.write_new(&out.doc)?;
|
||||
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
|
||||
}
|
||||
|
||||
/// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one
|
||||
@@ -3725,7 +3796,17 @@ impl Session {
|
||||
/// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes
|
||||
/// and password; a source without a key is a file of its own.
|
||||
pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
// Each file opened once: `file[i]` is source i's place in `docs`.
|
||||
Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
|
||||
}
|
||||
|
||||
/// [`Self::combine_grouped`] reporting what the conflict resolution renamed.
|
||||
pub fn combine_grouped_reporting(
|
||||
&self,
|
||||
sources: &[CombineSource],
|
||||
groups: &[u64],
|
||||
passwords: &[Option<&str>],
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts,
|
||||
) -> Result<Combined, EditError> {
|
||||
let (mut keys, mut docs, mut file): (Vec<Option<u64>>, Vec<pdfcraft_cos::Document>, Vec<usize>) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (i, (name, bytes, _)) in sources.iter().enumerate() {
|
||||
let key = groups.get(i).copied();
|
||||
@@ -3759,8 +3840,9 @@ impl Session {
|
||||
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
|
||||
runs.push((*at, name.as_str(), d, p.as_deref()));
|
||||
}
|
||||
let out = pdfcraft_organize::combine_grouped(&runs)?;
|
||||
self.write_new(&out)
|
||||
let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?;
|
||||
let bytes = self.write_new(&out.doc)?;
|
||||
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
|
||||
}
|
||||
|
||||
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
|
||||
|
||||
@@ -118,6 +118,43 @@ pub struct SignatureImage {
|
||||
size: [usize; 2],
|
||||
}
|
||||
|
||||
/// Decode an imported PNG or JPEG within the source limits (file size, 32 megapixels, 8192 pixels
|
||||
/// per side, bounded allocation), upright per its EXIF orientation and scaled down to at most
|
||||
/// 4 megapixels and 4096 pixels per side. Shared by image signatures and scanned photos.
|
||||
pub(crate) fn decode_source(bytes: &[u8]) -> Result<DynamicImage, SignatureImageError> {
|
||||
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
|
||||
return Err(SignatureImageError::Size);
|
||||
}
|
||||
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
|
||||
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
|
||||
return Err(SignatureImageError::Format);
|
||||
}
|
||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_SOURCE_SIDE);
|
||||
limits.max_image_height = Some(MAX_SOURCE_SIDE);
|
||||
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
|
||||
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
|
||||
reader.limits(limits);
|
||||
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
|
||||
let mut decoder = reader.into_decoder().map_err(too_big)?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
|
||||
return Err(SignatureImageError::Size);
|
||||
}
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
|
||||
image.apply_orientation(orientation);
|
||||
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
|
||||
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
|
||||
if scale < 1.0 {
|
||||
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
|
||||
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
|
||||
image = image.thumbnail(nw, nh);
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
impl SignatureImage {
|
||||
/// Read at most the file limit plus one byte, even if the file grows while being read.
|
||||
pub fn read(reader: impl Read) -> Result<Self, SignatureImageError> {
|
||||
@@ -127,36 +164,7 @@ impl SignatureImage {
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignatureImageError> {
|
||||
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
|
||||
return Err(SignatureImageError::Size);
|
||||
}
|
||||
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
|
||||
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
|
||||
return Err(SignatureImageError::Format);
|
||||
}
|
||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_SOURCE_SIDE);
|
||||
limits.max_image_height = Some(MAX_SOURCE_SIDE);
|
||||
// 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size.
|
||||
limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4);
|
||||
reader.limits(limits);
|
||||
let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() };
|
||||
let mut decoder = reader.into_decoder().map_err(too_big)?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS {
|
||||
return Err(SignatureImageError::Size);
|
||||
}
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?;
|
||||
image.apply_orientation(orientation);
|
||||
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
|
||||
let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt());
|
||||
if scale < 1.0 {
|
||||
let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1));
|
||||
// `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps.
|
||||
image = image.thumbnail(nw, nh);
|
||||
}
|
||||
let image = decode_source(bytes)?;
|
||||
let rgba = image.into_rgba8();
|
||||
let size = [rgba.width() as usize, rgba.height() as usize];
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//! Signatures scanned from a photo of paper (Fill & Sign ▸ Sign ▸ Scan).
|
||||
//!
|
||||
//! People sign a sheet of paper and photograph it, usually with a phone. Such a photo is far from
|
||||
//! a clean signature image: it shows the table around the sheet, shadows of the phone or hand,
|
||||
//! uneven light and sensor noise, and it is often larger than an image signature may be. This
|
||||
//! module turns the framed part of the photo into a [`SignatureImage`]: the ink in its own colour
|
||||
//! on a transparent background, trimmed, so it is placed, saved and reused like any image
|
||||
//! signature.
|
||||
//!
|
||||
//! Everything is local and bounded: the photo is decoded within the same limits as an imported
|
||||
//! image signature (a 4 MiB file, 32 megapixels, 8192 pixels per side), downscaled to at most
|
||||
//! 4 megapixels and [`WORKING_MAX_SIDE`] pixels per side, and only that copy is kept. So one
|
||||
//! scan of the whole frame touches at most 4 megapixels.
|
||||
//!
|
||||
//! The ink is separated from the paper in four steps:
|
||||
//! 1. **Paper estimate.** A grayscale morphological closing (a maximum filter, then a minimum
|
||||
//! filter, van Herk/Gil-Werman, O(1) per pixel) removes every dark detail narrower than the
|
||||
//! window, i.e. the pen strokes, while following lighting gradients and sharp shadow edges.
|
||||
//! Bright noise peaks are clipped first, and edge pixels are repeated outside the image, so a
|
||||
//! table cut off by the frame keeps its size and stays background.
|
||||
//! 2. **Darkness** of each pixel relative to that paper, slightly smoothed so that glossy
|
||||
//! ballpoint strokes with a lighter centre come out solid. The border of large dark areas
|
||||
//! that are not paper (the table) is cleared; shadows on the paper are kept.
|
||||
//! 3. **Hysteresis.** Pixels darker than the threshold are ink; fainter pixels are kept only when
|
||||
//! they belong to a blob with real ink, so faint stroke ends survive and stray marks don't.
|
||||
//! Blobs with too little ink are dropped as dust.
|
||||
//! 4. **Output.** The ink is trimmed with a small margin, drawn in its average colour with the
|
||||
//! coverage as alpha, and normalized through [`SignatureImage`].
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageFormat, RgbImage};
|
||||
|
||||
use crate::signature_image::{SignatureImage, SignatureImageError};
|
||||
|
||||
/// Largest photo file accepted: the image signature limit (decoding shares its limits too).
|
||||
pub const MAX_PHOTO_BYTES: usize = crate::signature_image::MAX_SIGNATURE_IMAGE_BYTES;
|
||||
/// Longest side of the copy that is kept and scanned (it also holds at most 4 megapixels).
|
||||
pub const WORKING_MAX_SIDE: u32 = 4096;
|
||||
/// Longest side of the framed area that is cleaned for the final signature.
|
||||
pub const SCAN_MAX_SIDE: u32 = 2400;
|
||||
/// Longest side used for a quick preview, e.g. while the frame is being dragged.
|
||||
pub const PREVIEW_MAX_SIDE: u32 = 900;
|
||||
/// Longest side of the downscaled photo used to find the signature.
|
||||
const DETECTION_MAX_SIDE: u32 = 1024;
|
||||
/// Largest signature produced, in pixels (the image signature limit).
|
||||
const MAX_OUTPUT_PIXELS: u64 = 4 << 20;
|
||||
|
||||
/// The default of the Ink sensitivity slider (0–100).
|
||||
pub const DEFAULT_SENSITIVITY: u8 = 50;
|
||||
/// Detection uses a higher sensitivity so that faint strokes are framed too.
|
||||
const DETECTION_SENSITIVITY: u8 = 70;
|
||||
/// Darkness threshold (0–255, relative to the paper) at sensitivity 0 and 100.
|
||||
const MAX_THRESHOLD: f32 = 100.0;
|
||||
const MIN_THRESHOLD: f32 = 12.0;
|
||||
/// Fainter pixels down to this fraction of the threshold are kept when they touch ink.
|
||||
const HYSTERESIS_RATIO: f32 = 0.5;
|
||||
/// Paper estimate window, relative to the photo's longest side; wider than half a felt-tip stroke.
|
||||
const BACKGROUND_RADIUS_RATIO: f32 = 0.012;
|
||||
const MIN_BACKGROUND_RADIUS: usize = 4;
|
||||
/// One pixel of smoothing per this many pixels of the longest side.
|
||||
const SMOOTHING_SCALE: usize = 1000;
|
||||
/// A background darker than this fraction of the nearby paper is not paper (e.g. the table).
|
||||
const DARK_AREA_RATIO: f32 = 0.45;
|
||||
/// Blobs with fewer ink pixels than this fraction of the area (at least the minimum) are dust.
|
||||
const SPECKLE_AREA_RATIO: f64 = 0.000_02;
|
||||
const MIN_SPECKLE_AREA: usize = 6;
|
||||
/// Blobs with less ink than this fraction of the largest one are not part of the signature.
|
||||
const DETECTION_MIN_INK_RATIO: f64 = 0.1;
|
||||
/// A detected signature this much taller than wide was photographed sideways.
|
||||
const UPRIGHT_RATIO: u32 = 2;
|
||||
/// Transparent margin around the trimmed signature, in pixels.
|
||||
const TRIM_PADDING: usize = 8;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ScanError {
|
||||
#[error("Choose a PNG or JPEG photo.")]
|
||||
Format,
|
||||
#[error("Choose a photo smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")]
|
||||
Size,
|
||||
#[error("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.")]
|
||||
NoInk,
|
||||
#[error("Couldn't read the photo: {0}")]
|
||||
Read(#[from] std::io::Error),
|
||||
#[error("Couldn't decode the photo: {0}")]
|
||||
Decode(#[from] image::ImageError),
|
||||
#[error(transparent)]
|
||||
Signature(#[from] SignatureImageError),
|
||||
}
|
||||
|
||||
/// A rectangle in photo pixels, top-left origin.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PhotoRect {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl PhotoRect {
|
||||
/// Keep the rectangle inside a `width`×`height` photo, at least one pixel large.
|
||||
pub fn clamped(self, width: u32, height: u32) -> Self {
|
||||
let w = self.width.clamp(1, width.max(1));
|
||||
let h = self.height.clamp(1, height.max(1));
|
||||
Self { x: self.x.min(width.saturating_sub(w)), y: self.y.min(height.saturating_sub(h)), width: w, height: h }
|
||||
}
|
||||
|
||||
/// The same area after the photo (`width`×`height` before turning) is turned a quarter.
|
||||
pub fn turned(self, turn: Turn, width: u32, height: u32) -> Self {
|
||||
match turn {
|
||||
Turn::Clockwise => {
|
||||
Self { x: height.saturating_sub(self.y.saturating_add(self.height)), y: self.x, width: self.height, height: self.width }
|
||||
}
|
||||
Turn::CounterClockwise => {
|
||||
Self { x: self.y, y: width.saturating_sub(self.x.saturating_add(self.width)), width: self.height, height: self.width }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quarter turn of the photo.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Turn {
|
||||
Clockwise,
|
||||
CounterClockwise,
|
||||
}
|
||||
|
||||
/// A decoded, upright and downscaled photo of a signature on paper.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SignaturePhoto {
|
||||
rgb: Arc<RgbImage>,
|
||||
}
|
||||
|
||||
impl SignaturePhoto {
|
||||
/// Read at most the file limit plus one byte, even if the file grows while being read.
|
||||
pub fn read(reader: impl Read) -> Result<Self, ScanError> {
|
||||
let mut bytes = Vec::new();
|
||||
reader.take(MAX_PHOTO_BYTES as u64 + 1).read_to_end(&mut bytes)?;
|
||||
Self::from_bytes(&bytes)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ScanError> {
|
||||
if bytes.len() > MAX_PHOTO_BYTES {
|
||||
return Err(ScanError::Size);
|
||||
}
|
||||
// The image signature decoder: same limits, EXIF orientation (phones store portrait
|
||||
// photos as landscape pixels plus an orientation), scaled down to 4 megapixels.
|
||||
let image = crate::signature_image::decode_source(bytes).map_err(|e| match e {
|
||||
SignatureImageError::Size => ScanError::Size,
|
||||
SignatureImageError::Format => ScanError::Format,
|
||||
e => ScanError::Signature(e),
|
||||
})?;
|
||||
Ok(Self { rgb: Arc::new(image.into_rgb8()) })
|
||||
}
|
||||
|
||||
/// Width and height in pixels.
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.rgb.width(), self.rgb.height()]
|
||||
}
|
||||
|
||||
/// The photo as RGB pixels, for showing it.
|
||||
pub fn rgb(&self) -> &RgbImage {
|
||||
&self.rgb
|
||||
}
|
||||
|
||||
/// The photo downscaled so that its longest side is at most `max_side`, as RGBA for display.
|
||||
pub fn thumbnail(&self, max_side: u32) -> image::RgbaImage {
|
||||
let [w, h] = self.size();
|
||||
let scale = (f64::from(max_side.max(1)) / f64::from(w.max(h).max(1))).min(1.0);
|
||||
let nw = ((f64::from(w) * scale).round() as u32).max(1);
|
||||
let nh = ((f64::from(h) * scale).round() as u32).max(1);
|
||||
DynamicImage::ImageRgb8(image::imageops::resize(&*self.rgb, nw, nh, image::imageops::FilterType::Triangle)).into_rgba8()
|
||||
}
|
||||
|
||||
/// Turn the photo a quarter, returning `rect` moved along with it.
|
||||
pub fn turn(&mut self, turn: Turn, rect: PhotoRect) -> PhotoRect {
|
||||
let [w, h] = self.size();
|
||||
self.rgb = Arc::new(match turn {
|
||||
Turn::Clockwise => image::imageops::rotate90(&*self.rgb),
|
||||
Turn::CounterClockwise => image::imageops::rotate270(&*self.rgb),
|
||||
});
|
||||
rect.turned(turn, w, h)
|
||||
}
|
||||
|
||||
/// Find the signature, or `None` when the photo shows no clear ink.
|
||||
pub fn detect(&self) -> Option<PhotoRect> {
|
||||
let [w, h] = self.size();
|
||||
let small = scaled(&self.rgb, PhotoRect { x: 0, y: 0, width: w, height: h }, DETECTION_MAX_SIDE);
|
||||
let (sw, sh) = (small.width(), small.height());
|
||||
let region = detect_region(&luminance(&small), sw as usize, sh as usize)?;
|
||||
let ratio = f64::from(w) / f64::from(sw.max(1));
|
||||
let scale = |v: usize| ((v as f64) * ratio).round().clamp(0.0, f64::from(u32::MAX)) as u32;
|
||||
Some(PhotoRect { x: scale(region.x), y: scale(region.y), width: scale(region.width), height: scale(region.height) }.clamped(w, h))
|
||||
}
|
||||
|
||||
/// Frame the signature for the user: turn a sideways signature upright, then return its
|
||||
/// frame, or a centred default frame when no signature was found.
|
||||
pub fn auto_frame(&mut self) -> PhotoRect {
|
||||
if let Some(rect) = self.detect() {
|
||||
if rect.height > rect.width.saturating_mul(UPRIGHT_RATIO) {
|
||||
// Signatures are written horizontally. The direction is a guess; the user can
|
||||
// turn the photo further.
|
||||
return self.turn(Turn::CounterClockwise, rect);
|
||||
}
|
||||
return rect;
|
||||
}
|
||||
let [w, h] = self.size();
|
||||
PhotoRect { x: w / 7, y: h * 7 / 20, width: w * 5 / 7, height: h * 3 / 10 }.clamped(w, h)
|
||||
}
|
||||
|
||||
/// Clean the framed area into a transparent signature. `max_side` bounds the processed area:
|
||||
/// [`SCAN_MAX_SIDE`] for the result, [`PREVIEW_MAX_SIDE`] for a quick preview.
|
||||
pub fn scan(&self, rect: PhotoRect, sensitivity: u8, max_side: u32) -> Result<SignatureImage, ScanError> {
|
||||
let [w, h] = self.size();
|
||||
let rect = rect.clamped(w, h);
|
||||
let area = scaled(&self.rgb, rect, max_side.clamp(16, WORKING_MAX_SIDE));
|
||||
let (aw, ah) = (area.width() as usize, area.height() as usize);
|
||||
// Size the paper estimate on the whole photo, not on the frame, so that thick strokes in
|
||||
// a tight frame are not taken for paper.
|
||||
let scale = aw as f32 / rect.width.max(1) as f32;
|
||||
let radius = (w.max(h) as f32 * BACKGROUND_RADIUS_RATIO * scale).round() as usize;
|
||||
let ink = extract_ink(&luminance(&area), aw, ah, Options { sensitivity, despeckle: true, background_radius: Some(radius) });
|
||||
let bounds = ink.bounds.ok_or(ScanError::NoInk)?;
|
||||
let png = render(&area, &ink.alpha, bounds)?;
|
||||
Ok(SignatureImage::from_bytes(&png)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The framed area of `rgb`, downscaled so that its longest side is at most `max_side`.
|
||||
fn scaled(rgb: &RgbImage, rect: PhotoRect, max_side: u32) -> RgbImage {
|
||||
let rect = rect.clamped(rgb.width(), rgb.height());
|
||||
let view = image::imageops::crop_imm(rgb, rect.x, rect.y, rect.width, rect.height).to_image();
|
||||
let longest = rect.width.max(rect.height);
|
||||
if longest <= max_side {
|
||||
return view;
|
||||
}
|
||||
let scale = f64::from(max_side) / f64::from(longest);
|
||||
let nw = ((f64::from(rect.width) * scale).round() as u32).max(1);
|
||||
let nh = ((f64::from(rect.height) * scale).round() as u32).max(1);
|
||||
image::imageops::resize(&view, nw, nh, image::imageops::FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Perceived luminance (ITU BT.601).
|
||||
fn luminance(rgb: &RgbImage) -> Vec<u8> {
|
||||
rgb.pixels().map(|p| (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round().clamp(0.0, 255.0) as u8).collect()
|
||||
}
|
||||
|
||||
/// A rectangle in the pixels of a processed buffer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Region {
|
||||
x: usize,
|
||||
y: usize,
|
||||
width: usize,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Options {
|
||||
sensitivity: u8,
|
||||
despeckle: bool,
|
||||
/// Paper estimate window; by default a fraction of the buffer's longest side.
|
||||
background_radius: Option<usize>,
|
||||
}
|
||||
|
||||
/// Ink coverage (0 = paper, 255 = solid ink) and the bounding box of the solid ink.
|
||||
struct Ink {
|
||||
alpha: Vec<u8>,
|
||||
bounds: Option<Region>,
|
||||
}
|
||||
|
||||
/// The darkness threshold for a sensitivity of 0–100; higher sensitivity keeps fainter strokes.
|
||||
fn threshold(sensitivity: u8) -> f32 {
|
||||
let s = f32::from(sensitivity.min(100)) / 100.0;
|
||||
MAX_THRESHOLD - s * (MAX_THRESHOLD - MIN_THRESHOLD)
|
||||
}
|
||||
|
||||
/// In-place separable box blur.
|
||||
fn box_blur(values: &mut [f32], width: usize, height: usize, radius: usize) {
|
||||
if radius == 0 || width == 0 || height == 0 || values.len() != width * height {
|
||||
return;
|
||||
}
|
||||
let mut line = vec![0.0_f32; width.max(height)];
|
||||
let mut blur = |get: &dyn Fn(usize) -> f32, len: usize, out: &mut dyn FnMut(usize, f32)| {
|
||||
for (i, slot) in line.iter_mut().take(len).enumerate() {
|
||||
*slot = get(i);
|
||||
}
|
||||
let (mut sum, mut count) = (0.0_f32, 0_u32);
|
||||
for v in line.iter().take(radius.min(len)) {
|
||||
sum += v;
|
||||
count += 1;
|
||||
}
|
||||
for i in 0..len {
|
||||
if let Some(v) = line.get(i + radius).filter(|_| i + radius < len) {
|
||||
sum += v;
|
||||
count += 1;
|
||||
}
|
||||
if let Some(v) = i.checked_sub(radius + 1).and_then(|j| line.get(j)) {
|
||||
sum -= v;
|
||||
count = count.saturating_sub(1);
|
||||
}
|
||||
out(i, if count == 0 { 0.0 } else { sum / count as f32 });
|
||||
}
|
||||
};
|
||||
let mut row = vec![0.0_f32; width];
|
||||
let mut tmp = vec![0.0_f32; width];
|
||||
for y in 0..height {
|
||||
let start = y * width;
|
||||
let Some(src) = values.get(start..start + width) else { return };
|
||||
row.copy_from_slice(src);
|
||||
blur(&|x| row.get(x).copied().unwrap_or(0.0), width, &mut |x, v| {
|
||||
if let Some(slot) = tmp.get_mut(x) {
|
||||
*slot = v;
|
||||
}
|
||||
});
|
||||
if let Some(dst) = values.get_mut(start..start + width) {
|
||||
dst.copy_from_slice(&tmp);
|
||||
}
|
||||
}
|
||||
let mut column = vec![0.0_f32; height];
|
||||
let mut tmp = vec![0.0_f32; height];
|
||||
for x in 0..width {
|
||||
for (y, slot) in column.iter_mut().enumerate() {
|
||||
*slot = values.get(y * width + x).copied().unwrap_or(0.0);
|
||||
}
|
||||
blur(&|y| column.get(y).copied().unwrap_or(0.0), height, &mut |y, v| {
|
||||
if let Some(slot) = tmp.get_mut(y) {
|
||||
*slot = v;
|
||||
}
|
||||
});
|
||||
for (y, v) in tmp.iter().enumerate() {
|
||||
if let Some(slot) = values.get_mut(y * width + x) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliding-window maximum or minimum over `radius` pixels on each side (van Herk/Gil-Werman).
|
||||
/// Edge values are repeated outside the line.
|
||||
fn sliding_extremum(src: &[u8], dst: &mut [u8], radius: usize, max: bool) {
|
||||
let len = src.len().min(dst.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let pick = |a: u8, b: u8| if max { a.max(b) } else { a.min(b) };
|
||||
let size = 2 * radius + 1;
|
||||
let total = len + 2 * radius;
|
||||
let at = |i: usize| src.get(i.saturating_sub(radius).min(len - 1)).copied().unwrap_or(0);
|
||||
let mut g = vec![0_u8; total];
|
||||
let mut h = vec![0_u8; total];
|
||||
let mut prev = 0;
|
||||
for (i, slot) in g.iter_mut().enumerate() {
|
||||
let v = at(i);
|
||||
prev = if i % size == 0 { v } else { pick(prev, v) };
|
||||
*slot = prev;
|
||||
}
|
||||
let mut next = 0;
|
||||
for (i, slot) in h.iter_mut().enumerate().rev() {
|
||||
let v = at(i);
|
||||
next = if i == total - 1 || (i + 1) % size == 0 { v } else { pick(next, v) };
|
||||
*slot = next;
|
||||
}
|
||||
for (i, out) in dst.iter_mut().take(len).enumerate() {
|
||||
if let (Some(&a), Some(&b)) = (h.get(i), g.get(i + 2 * radius)) {
|
||||
*out = pick(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Separable square maximum (`max`) or minimum filter.
|
||||
fn square_filter(src: &[u8], width: usize, height: usize, radius: usize, max: bool) -> Vec<u8> {
|
||||
let mut rows = vec![0_u8; src.len()];
|
||||
for (s, d) in src.chunks_exact(width.max(1)).zip(rows.chunks_exact_mut(width.max(1))) {
|
||||
sliding_extremum(s, d, radius, max);
|
||||
}
|
||||
let mut out = vec![0_u8; src.len()];
|
||||
let mut column = vec![0_u8; height];
|
||||
let mut filtered = vec![0_u8; height];
|
||||
for x in 0..width {
|
||||
for (y, slot) in column.iter_mut().enumerate() {
|
||||
*slot = rows.get(y * width + x).copied().unwrap_or(0);
|
||||
}
|
||||
sliding_extremum(&column, &mut filtered, radius, max);
|
||||
for (y, v) in filtered.iter().enumerate() {
|
||||
if let Some(slot) = out.get_mut(y * width + x) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The brightness of the paper behind every pixel (see the module documentation).
|
||||
fn estimate_background(gray: &[u8], width: usize, height: usize, radius: usize) -> Vec<u8> {
|
||||
let r = radius.max(MIN_BACKGROUND_RADIUS);
|
||||
// Clip bright noise peaks, which the maximum filter would otherwise pick, so that the faint
|
||||
// halo around every stroke doesn't count as ink. Only clipping keeps shadow edges sharp.
|
||||
let mut blurred: Vec<f32> = gray.iter().map(|&v| f32::from(v)).collect();
|
||||
box_blur(&mut blurred, width, height, (r / 6).max(1));
|
||||
let clipped: Vec<u8> = gray.iter().zip(&blurred).map(|(&g, &b)| f32::from(g).min(b).round().clamp(0.0, 255.0) as u8).collect();
|
||||
let closed = square_filter(&square_filter(&clipped, width, height, r, true), width, height, r, false);
|
||||
let mut smooth: Vec<f32> = closed.iter().map(|&v| f32::from(v)).collect();
|
||||
box_blur(&mut smooth, width, height, (r / 4).max(1));
|
||||
// The blur must never make the paper darker than the photo itself.
|
||||
smooth.iter().zip(gray).map(|(&s, &g)| s.max(f32::from(g)).round().clamp(1.0, 255.0) as u8).collect()
|
||||
}
|
||||
|
||||
/// Label 8-connected blobs of non-zero `mask` pixels; returns the labels (0 = none) and count.
|
||||
fn label_components(mask: &[bool], width: usize, height: usize) -> (Vec<u32>, usize) {
|
||||
let mut labels = vec![0_u32; mask.len()];
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut count = 0_usize;
|
||||
for start in 0..mask.len() {
|
||||
if !mask.get(start).copied().unwrap_or(false) || labels.get(start).copied().unwrap_or(1) != 0 {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let label = u32::try_from(count).unwrap_or(u32::MAX);
|
||||
if let Some(l) = labels.get_mut(start) {
|
||||
*l = label;
|
||||
}
|
||||
stack.push(start);
|
||||
while let Some(index) = stack.pop() {
|
||||
let (x, y) = (index % width, index / width);
|
||||
for ny in y.saturating_sub(1)..=(y + 1).min(height.saturating_sub(1)) {
|
||||
for nx in x.saturating_sub(1)..=(x + 1).min(width.saturating_sub(1)) {
|
||||
let n = ny * width + nx;
|
||||
if mask.get(n).copied().unwrap_or(false)
|
||||
&& let Some(l) = labels.get_mut(n)
|
||||
&& *l == 0
|
||||
{
|
||||
*l = label;
|
||||
stack.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(labels, count)
|
||||
}
|
||||
|
||||
/// Separate the ink from the paper (see the module documentation).
|
||||
fn extract_ink(gray: &[u8], width: usize, height: usize, options: Options) -> Ink {
|
||||
let n = width * height;
|
||||
if n == 0 || gray.len() != n {
|
||||
return Ink { alpha: vec![0; gray.len()], bounds: None };
|
||||
}
|
||||
let radius =
|
||||
options.background_radius.unwrap_or_else(|| (width.max(height) as f32 * BACKGROUND_RADIUS_RATIO).round() as usize).max(MIN_BACKGROUND_RADIUS);
|
||||
let background = estimate_background(gray, width, height, radius);
|
||||
|
||||
let mut darkness: Vec<f32> =
|
||||
gray.iter().zip(&background).map(|(&g, &bg)| if g >= bg { 0.0 } else { 255.0 * f32::from(bg - g) / f32::from(bg.max(1)) }).collect();
|
||||
let smoothing = width.max(height) / SMOOTHING_SCALE;
|
||||
box_blur(&mut darkness, width, height, smoothing);
|
||||
|
||||
// Where the sheet meets a much darker area (the table) is not ink; a shadow on the paper is
|
||||
// only moderately darker and is kept.
|
||||
let edge = smoothing + 2;
|
||||
let brightest = square_filter(&background, width, height, edge, true);
|
||||
let darkest = square_filter(&background, width, height, edge, false);
|
||||
for ((d, &lo), &hi) in darkness.iter_mut().zip(&darkest).zip(&brightest) {
|
||||
if f32::from(lo) < f32::from(hi) * DARK_AREA_RATIO {
|
||||
*d = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
let strong = threshold(options.sensitivity);
|
||||
let weak = strong * HYSTERESIS_RATIO;
|
||||
let candidates: Vec<bool> = darkness.iter().map(|&d| d >= weak).collect();
|
||||
let (labels, count) = label_components(&candidates, width, height);
|
||||
let mut strong_pixels = vec![0_usize; count + 1];
|
||||
for (&label, &d) in labels.iter().zip(&darkness) {
|
||||
if label != 0
|
||||
&& d >= strong
|
||||
&& let Some(c) = strong_pixels.get_mut(label as usize)
|
||||
{
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
let min_strong = if options.despeckle { MIN_SPECKLE_AREA.max((n as f64 * SPECKLE_AREA_RATIO).round() as usize) } else { 1 };
|
||||
|
||||
let mut alpha = vec![0_u8; n];
|
||||
let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0, 0);
|
||||
for (i, ((a, &label), &d)) in alpha.iter_mut().zip(&labels).zip(&darkness).enumerate() {
|
||||
if label == 0 || strong_pixels.get(label as usize).copied().unwrap_or(0) < min_strong {
|
||||
continue;
|
||||
}
|
||||
// Smooth ramp from the weak to the strong threshold.
|
||||
let c = ((d - weak) / (strong - weak).max(f32::EPSILON)).clamp(0.0, 1.0);
|
||||
*a = (255.0 * c * c * (3.0 - 2.0 * c)).round() as u8;
|
||||
if *a >= 128 {
|
||||
let (x, y) = (i % width, i / width);
|
||||
min_x = min_x.min(x);
|
||||
min_y = min_y.min(y);
|
||||
max_x = max_x.max(x);
|
||||
max_y = max_y.max(y);
|
||||
}
|
||||
}
|
||||
let bounds = (min_x <= max_x && min_y <= max_y).then(|| Region { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 });
|
||||
Ink { alpha, bounds }
|
||||
}
|
||||
|
||||
/// Where the signature is in a (downscaled) photo of the whole sheet: nearby strokes are merged,
|
||||
/// blobs touching the photo border (paper edges, table, fingers) are ignored, and the blob with
|
||||
/// the most ink is framed together with the other words on its line.
|
||||
fn detect_region(gray: &[u8], width: usize, height: usize) -> Option<Region> {
|
||||
let ink = extract_ink(gray, width, height, Options { sensitivity: DETECTION_SENSITIVITY, despeckle: true, background_radius: None });
|
||||
ink.bounds?;
|
||||
let solid: Vec<u8> = ink.alpha.iter().map(|&a| if a >= 128 { 255 } else { 0 }).collect();
|
||||
let radius = ((width.max(height) as f64) * 0.03).round().max(2.0) as usize;
|
||||
let merged: Vec<bool> = square_filter(&solid, width, height, radius, true).iter().map(|&v| v != 0).collect();
|
||||
let (labels, count) = label_components(&merged, width, height);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Blob {
|
||||
ink: usize,
|
||||
min_x: usize,
|
||||
min_y: usize,
|
||||
max_x: usize,
|
||||
max_y: usize,
|
||||
border: bool,
|
||||
}
|
||||
let mut blobs = vec![Blob { ink: 0, min_x: usize::MAX, min_y: usize::MAX, max_x: 0, max_y: 0, border: false }; count + 1];
|
||||
for (i, (&label, &s)) in labels.iter().zip(&solid).enumerate() {
|
||||
let Some(blob) = blobs.get_mut(label as usize).filter(|_| label != 0) else { continue };
|
||||
let (x, y) = (i % width, i / width);
|
||||
if x == 0 || y == 0 || x + 1 == width || y + 1 == height {
|
||||
blob.border = true;
|
||||
}
|
||||
if s != 0 {
|
||||
blob.ink += 1;
|
||||
blob.min_x = blob.min_x.min(x);
|
||||
blob.min_y = blob.min_y.min(y);
|
||||
blob.max_x = blob.max_x.max(x);
|
||||
blob.max_y = blob.max_y.max(y);
|
||||
}
|
||||
}
|
||||
let candidates = || blobs.iter().skip(1).filter(|b| !b.border && b.ink > 0);
|
||||
let best = *candidates().max_by_key(|b| b.ink)?;
|
||||
// Signatures often have several words: add every blob with a fair amount of ink on the same line.
|
||||
let span = best.max_y - best.min_y;
|
||||
let (top, bottom) = (best.min_y.saturating_sub(span), best.max_y + span);
|
||||
let min_ink = best.ink as f64 * DETECTION_MIN_INK_RATIO;
|
||||
let mut frame = best;
|
||||
for b in candidates().filter(|b| b.ink as f64 >= min_ink && b.max_y >= top && b.min_y <= bottom) {
|
||||
frame.min_x = frame.min_x.min(b.min_x);
|
||||
frame.min_y = frame.min_y.min(b.min_y);
|
||||
frame.max_x = frame.max_x.max(b.max_x);
|
||||
frame.max_y = frame.max_y.max(b.max_y);
|
||||
}
|
||||
let pad = radius;
|
||||
let (x, y) = (frame.min_x.saturating_sub(pad), frame.min_y.saturating_sub(pad));
|
||||
Some(Region { x, y, width: (frame.max_x + 1 + pad).min(width) - x, height: (frame.max_y + 1 + pad).min(height) - y })
|
||||
}
|
||||
|
||||
/// The trimmed ink in its average colour with the coverage as alpha, as PNG.
|
||||
fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result<Vec<u8>, ScanError> {
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let x0 = bounds.x.saturating_sub(TRIM_PADDING);
|
||||
let y0 = bounds.y.saturating_sub(TRIM_PADDING);
|
||||
let x1 = (bounds.x + bounds.width + TRIM_PADDING).min(w);
|
||||
let y1 = (bounds.y + bounds.height + TRIM_PADDING).min(h);
|
||||
let (ow, oh) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
|
||||
if ow == 0 || oh == 0 {
|
||||
return Err(ScanError::NoInk);
|
||||
}
|
||||
|
||||
// One ink colour: the average of the solid strokes, so blue ink stays blue and paper tint at
|
||||
// the stroke edges doesn't show.
|
||||
let (mut sum, mut solid) = ([0_u64; 3], 0_u64);
|
||||
for (p, &a) in area.pixels().zip(alpha) {
|
||||
if a == 255 {
|
||||
for (s, &c) in sum.iter_mut().zip(p.0.iter()) {
|
||||
*s += u64::from(c);
|
||||
}
|
||||
solid += 1;
|
||||
}
|
||||
}
|
||||
let colour = if solid == 0 { [0, 0, 0] } else { sum.map(|s| (s / solid).min(255) as u8) };
|
||||
|
||||
let mut out = image::RgbaImage::new(u32::try_from(ow).unwrap_or(1), u32::try_from(oh).unwrap_or(1));
|
||||
for (y, row) in out.rows_mut().enumerate() {
|
||||
for (x, px) in row.enumerate() {
|
||||
let a = alpha.get((y0 + y) * w + x0 + x).copied().unwrap_or(0);
|
||||
*px = image::Rgba([colour[0], colour[1], colour[2], a]);
|
||||
}
|
||||
}
|
||||
let mut out = DynamicImage::ImageRgba8(out);
|
||||
let pixels = (ow as u64).saturating_mul(oh as u64);
|
||||
if pixels > MAX_OUTPUT_PIXELS || ow > WORKING_MAX_SIDE as usize || oh > WORKING_MAX_SIDE as usize {
|
||||
let scale = (MAX_OUTPUT_PIXELS as f64 / pixels as f64).sqrt().min(f64::from(WORKING_MAX_SIDE) / ow.max(oh) as f64);
|
||||
let nw = ((ow as f64 * scale).floor() as u32).max(1);
|
||||
let nh = ((oh as f64 * scale).floor() as u32).max(1);
|
||||
out = out.resize_exact(nw, nh, image::imageops::FilterType::Triangle);
|
||||
}
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
out.write_to(&mut png, ImageFormat::Png)?;
|
||||
// A PNG larger than a saved signature may be is scaled down by `SignatureImage::from_bytes`.
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`.
|
||||
fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec<u8> {
|
||||
let mut out = vec![0; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]);
|
||||
out[y * width + x] = if inside { ink_value } else { paper(x, y) };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage {
|
||||
RgbImage::from_fn(width as u32, height as u32, |x, y| {
|
||||
let v = gray[y as usize * width + x as usize];
|
||||
image::Rgb([v, v, v])
|
||||
})
|
||||
}
|
||||
|
||||
fn jpeg(image: &RgbImage) -> Vec<u8> {
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image.write_to(&mut out, ImageFormat::Jpeg).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and
|
||||
/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels.
|
||||
#[test]
|
||||
fn photos_keep_the_image_signature_limits() {
|
||||
let png = |image: &RgbImage| {
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image.write_to(&mut out, ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
|
||||
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
|
||||
assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size)));
|
||||
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
|
||||
let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size();
|
||||
assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}");
|
||||
assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}");
|
||||
}
|
||||
|
||||
fn region(r: [usize; 4]) -> Region {
|
||||
Region { x: r[0], y: r[1], width: r[2], height: r[3] }
|
||||
}
|
||||
|
||||
fn opts() -> Options {
|
||||
Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_averages_and_keeps_a_constant_signal() {
|
||||
let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0];
|
||||
box_blur(&mut values, 5, 1, 1);
|
||||
assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]);
|
||||
let mut flat = vec![7.0; 12];
|
||||
box_blur(&mut flat, 4, 3, 2);
|
||||
assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_filters_match_a_naive_window_with_repeated_edges() {
|
||||
let (w, h) = (13, 7);
|
||||
let src: Vec<u8> = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect();
|
||||
for radius in [1, 2, 5] {
|
||||
for max in [true, false] {
|
||||
let fast = square_filter(&src, w, h, radius, max);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut want = if max { 0 } else { 255 };
|
||||
for dy in -(radius as i64)..=radius as i64 {
|
||||
for dx in -(radius as i64)..=radius as i64 {
|
||||
let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize;
|
||||
let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize;
|
||||
let v = src[sy * w + sx];
|
||||
want = if max { want.max(v) } else { want.min(v) };
|
||||
}
|
||||
}
|
||||
assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_are_eight_connected() {
|
||||
let mask = [true, false, false, false, false, true, false, true, false, false, false, false];
|
||||
let (labels, count) = label_components(&mask, 4, 3);
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(labels[0], labels[5]);
|
||||
assert_ne!(labels[7], labels[0]);
|
||||
assert_eq!(labels[1], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_lowers_the_threshold() {
|
||||
assert!(threshold(0) > threshold(50));
|
||||
assert!(threshold(50) > threshold(100));
|
||||
assert_eq!(threshold(200), threshold(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() {
|
||||
let (w, h) = (160, 80);
|
||||
let stroke = [20, 30, 120, 6];
|
||||
let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30);
|
||||
let ink = extract_ink(&gray, w, h, opts());
|
||||
assert_eq!(ink.alpha[10 * w + 150], 0);
|
||||
assert_eq!(ink.alpha[33 * w + 30], 255);
|
||||
assert_eq!(ink.alpha[33 * w + 130], 255);
|
||||
assert_eq!(ink.bounds, Some(region(stroke)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sharp_shadow_edge_is_not_ink() {
|
||||
let (w, h) = (200, 120);
|
||||
let stroke = [30, 52, 140, 4];
|
||||
let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30);
|
||||
let ink = extract_ink(&gray, w, h, opts());
|
||||
for y in 58..63 {
|
||||
assert_eq!(ink.alpha[y * w + 10], 0);
|
||||
assert_eq!(ink.alpha[y * w + 190], 0);
|
||||
}
|
||||
assert_eq!(ink.bounds, Some(region(stroke)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_at_the_edge_of_the_frame_is_not_ink() {
|
||||
let (w, h) = (300, 120);
|
||||
let stroke = [60, 60, 200, 4];
|
||||
let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30);
|
||||
let ink = extract_ink(&gray, w, h, opts());
|
||||
assert_eq!(ink.alpha[5 * w + 150], 0);
|
||||
assert_eq!(ink.alpha[60 * w + 5], 0);
|
||||
assert_eq!(ink.alpha[11 * w + 150], 0);
|
||||
assert_eq!(ink.bounds, Some(region(stroke)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() {
|
||||
let (w, h) = (600, 200);
|
||||
let stroke = [50, 90, 500, 18];
|
||||
let gray = photo(w, h, |_, _| 225, &[stroke], 30);
|
||||
let middle = 99 * w + 300;
|
||||
// Sized on the tight frame alone, the window is narrower than the stroke.
|
||||
assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0);
|
||||
let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() });
|
||||
assert_eq!(ink.bounds, Some(region(stroke)));
|
||||
assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() {
|
||||
let (w, h) = (200, 60);
|
||||
let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40);
|
||||
for y in 28..32 {
|
||||
for x in (100..180).chain(10..15) {
|
||||
gray[y * w + x] = 175;
|
||||
}
|
||||
}
|
||||
let ink = extract_ink(&gray, w, h, opts());
|
||||
assert!(ink.alpha[30 * w + 150] > 0);
|
||||
assert_eq!(ink.alpha[30 * w + 12], 0);
|
||||
assert_eq!(ink.bounds, Some(region([20, 28, 160, 4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dust_is_removed_unless_despeckling_is_off() {
|
||||
let (w, h) = (200, 100);
|
||||
let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20);
|
||||
assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0);
|
||||
let ink = extract_ink(&gray, w, h, opts());
|
||||
assert_eq!(ink.alpha[5 * w + 190], 0);
|
||||
assert_eq!(ink.bounds, Some(region([20, 40, 150, 5])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faint_strokes_need_a_higher_sensitivity() {
|
||||
let (w, h) = (120, 60);
|
||||
let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165);
|
||||
assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none());
|
||||
assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_paper_has_no_ink() {
|
||||
let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0);
|
||||
assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none());
|
||||
assert!(detect_region(&gray, 64, 64).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() {
|
||||
let (w, h) = (400, 200);
|
||||
let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30);
|
||||
let r = detect_region(&gray, w, h).unwrap();
|
||||
assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}");
|
||||
assert!(r.y > 20 && r.y + r.height < 185, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() {
|
||||
let (w, h) = (400, 300);
|
||||
let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 };
|
||||
let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30);
|
||||
let r = detect_region(&gray, w, h).unwrap();
|
||||
assert!(r.x > 80 && r.x + r.width < 320, "{r:?}");
|
||||
assert!(r.y > 70 && r.y + r.height < 230, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rects_turn_with_the_photo_and_back() {
|
||||
let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 };
|
||||
let turned = rect.turned(Turn::Clockwise, 100, 80);
|
||||
assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 });
|
||||
assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect);
|
||||
assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 });
|
||||
assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() {
|
||||
let (w, h) = (600, 400);
|
||||
let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h);
|
||||
for (x, y, p) in image.enumerate_pixels_mut() {
|
||||
if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) {
|
||||
*p = image::Rgb([30, 50, 140]);
|
||||
}
|
||||
}
|
||||
let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap();
|
||||
let rect = photo.auto_frame();
|
||||
assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}");
|
||||
let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap();
|
||||
let [sw, sh] = signature.size();
|
||||
assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}");
|
||||
let pixels = signature.rgba().as_chunks::<4>().0;
|
||||
assert_eq!(pixels[0][3], 0, "the margin is transparent");
|
||||
let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect();
|
||||
assert!(!solid.is_empty());
|
||||
assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]);
|
||||
let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap();
|
||||
assert!(quick.size()[0] <= sw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sideways_signature_is_turned_upright() {
|
||||
let (w, h) = (400, 600);
|
||||
let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30);
|
||||
let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap();
|
||||
let rect = photo.auto_frame();
|
||||
assert_eq!(photo.size(), [600, 400]);
|
||||
assert!(rect.width > rect.height * 2, "{rect:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_frame_reports_that_no_signature_was_found() {
|
||||
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap();
|
||||
let mut copy = photo.clone();
|
||||
assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 });
|
||||
assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_photos_are_downscaled_and_invalid_ones_rejected() {
|
||||
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap();
|
||||
let [w, h] = photo.size();
|
||||
assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}");
|
||||
assert!(photo.thumbnail(512).width() <= 512);
|
||||
assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format)));
|
||||
assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format)));
|
||||
assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err());
|
||||
assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size)));
|
||||
assert!(SignaturePhoto::read(std::io::repeat(0)).is_err());
|
||||
// A header claiming a huge image is rejected before decoding.
|
||||
let mut png = Vec::new();
|
||||
png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR");
|
||||
png.extend_from_slice(&20_000_u32.to_be_bytes());
|
||||
png.extend_from_slice(&20_000_u32.to_be_bytes());
|
||||
png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert!(SignaturePhoto::from_bytes(&png).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_buffers_do_not_panic() {
|
||||
assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none());
|
||||
assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none());
|
||||
assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none());
|
||||
assert!(detect_region(&[255; 4], 2, 2).is_none());
|
||||
let mut empty: Vec<f32> = Vec::new();
|
||||
box_blur(&mut empty, 0, 0, 3);
|
||||
assert!(square_filter(&[], 0, 0, 2, true).is_empty());
|
||||
let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap();
|
||||
let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0);
|
||||
}
|
||||
}
|
||||
+152
-3
@@ -430,15 +430,134 @@ fn combine_extract_split_and_insert_from_file() {
|
||||
let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap();
|
||||
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
|
||||
// Insert pages from a file, undoably, into the open document.
|
||||
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
|
||||
s.apply(
|
||||
id,
|
||||
Edit::InsertPagesFrom {
|
||||
name: "b.pdf".into(),
|
||||
bytes: other,
|
||||
pages: Some(vec![1]),
|
||||
at: 1,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
|
||||
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
|
||||
assert_eq!(page_texts(&s, id).len(), 3);
|
||||
// Garbage sources fail cleanly.
|
||||
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
|
||||
let bad = s.apply(
|
||||
id,
|
||||
Edit::InsertPagesFrom {
|
||||
name: "junk.pdf".into(),
|
||||
bytes: Arc::new(b"nope".to_vec()),
|
||||
pages: None,
|
||||
at: 0,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
},
|
||||
);
|
||||
assert!(matches!(bad, Err(EditError::Source(_))));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field).
|
||||
fn form_with_field(name: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 4
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Inserting pages of a file whose form names a field as the open document's: the incoming
|
||||
/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is
|
||||
/// not Combine), and nothing about renaming lands in the repair log.
|
||||
#[test]
|
||||
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
|
||||
s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log());
|
||||
}
|
||||
|
||||
/// field is renamed to keep the two separate, the repair log says so (warning that scripts
|
||||
/// were not rewritten), the undo step's label counts the renames, and undo puts the names back.
|
||||
#[test]
|
||||
fn insert_from_file_renames_colliding_fields_and_notes_it() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
|
||||
s.apply(
|
||||
id,
|
||||
Edit::InsertPagesFrom {
|
||||
name: "b.pdf".into(),
|
||||
bytes: Arc::new(form_with_field("Text1")),
|
||||
pages: None,
|
||||
at: 1,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Text1", "Text1-1"]);
|
||||
assert!(
|
||||
doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")),
|
||||
"the note names the rename and the scripts caveat: {:?}",
|
||||
doc.repair_log()
|
||||
);
|
||||
let label = s.undo(id).unwrap();
|
||||
assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed");
|
||||
let doc = s.get(id).unwrap();
|
||||
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Text1"], "undo restores the single original field");
|
||||
}
|
||||
|
||||
/// A combine reports the fields it renamed — the tool result tells callers what their scripts
|
||||
/// must call the field now — and honours a caller's choice to merge instead.
|
||||
#[test]
|
||||
fn combine_reports_renamed_fields_and_can_merge() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let sources: Vec<CombineSource> =
|
||||
vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)];
|
||||
let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
let cid = s.open_new("Combined.pdf", out.bytes).unwrap();
|
||||
let doc = s.get(cid).unwrap();
|
||||
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Text1", "Text1-1"]);
|
||||
let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap();
|
||||
assert!(merged.renamed_fields.is_empty(), "merge renames nothing");
|
||||
}
|
||||
|
||||
/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename
|
||||
/// in each. A file that cannot be opened previews as nothing (its row already shows the problem).
|
||||
#[test]
|
||||
fn combine_field_conflicts_previews_over_bytes() {
|
||||
let s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let sources: Vec<CombineSource> = vec![
|
||||
("a.pdf".into(), Arc::new(form_with_field("Text1")), None),
|
||||
("b.pdf".into(), Arc::new(form_with_field("Text1")), None),
|
||||
("junk.pdf".into(), Arc::new(b"nope".to_vec()), None),
|
||||
];
|
||||
let preview = s.combine_field_conflicts(&sources, &[]);
|
||||
assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]);
|
||||
}
|
||||
|
||||
/// A minimal baseline JPEG (headers only; the data is embedded as is).
|
||||
fn jpeg_bytes() -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xD8];
|
||||
@@ -1572,7 +1691,17 @@ fn create_and_reduce() {
|
||||
// A file with the same page imported twice shares nothing until reduced.
|
||||
let (mut s2, id2) = session_with(1);
|
||||
let src = Arc::new(fixture(1));
|
||||
s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap();
|
||||
s2.apply(
|
||||
id2,
|
||||
Edit::InsertPagesFrom {
|
||||
name: "x".into(),
|
||||
bytes: src.clone(),
|
||||
pages: None,
|
||||
at: 1,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let full = s2.save_full_bytes(id2).unwrap();
|
||||
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
|
||||
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
|
||||
@@ -2487,6 +2616,26 @@ BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body s
|
||||
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_export_positions_follow_the_displayed_crop_rotation_and_units() {
|
||||
use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full};
|
||||
let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap();
|
||||
let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone();
|
||||
page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec()));
|
||||
page.set("Rotate", Object::Int(90));
|
||||
page.set("UserUnit", Object::Int(2));
|
||||
pdf.set(ObjRef::new(4, 0), page);
|
||||
let mut session = Session::new();
|
||||
let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
let pages = doc.export_pages();
|
||||
let b = &pages[0].blocks[0];
|
||||
assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}");
|
||||
assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}");
|
||||
assert_eq!(b.size, 48.0);
|
||||
assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_turns_a_panic_into_an_error() {
|
||||
assert_eq!(guard(|| 7), Ok(7));
|
||||
|
||||
+262
-59
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
|
||||
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
|
||||
//! document: headings are the paragraphs set larger than the body text, and images sit where
|
||||
//! they fall between paragraphs.
|
||||
//! document: headings are the paragraphs set larger than the body text. Word keeps pages with
|
||||
//! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable
|
||||
//! shapes to avoid reflowing overlays; other pages flow with images inline.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -15,7 +16,7 @@ pub use zip::Zip;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Block {
|
||||
pub text: String,
|
||||
/// [x0, y0, x1, y1] in user space (y up).
|
||||
/// [x0, y0, x1, y1] on the displayed page in points (y up).
|
||||
pub rect: [f64; 4],
|
||||
/// Font size in points.
|
||||
pub size: f64,
|
||||
@@ -23,6 +24,8 @@ pub struct Block {
|
||||
pub italic: bool,
|
||||
/// Text fill colour as sRGB components in 0..=1 (black when unknown).
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in user space (degrees).
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// An image of a page.
|
||||
@@ -32,6 +35,10 @@ pub struct Image {
|
||||
pub ext: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
pub rect: [f64; 4],
|
||||
/// Width and height before rotation, in points.
|
||||
pub size: [f64; 2],
|
||||
/// Counterclockwise rotation in user space (degrees).
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -431,23 +438,15 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) ->
|
||||
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
|
||||
}
|
||||
|
||||
/// One Word table: explicit single borders (so it renders without a table style), a grid sized
|
||||
/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized
|
||||
/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes.
|
||||
fn docx_table(t: &Table) -> String {
|
||||
let mut edges = t.cols.clone();
|
||||
edges.push(t.rect[2]);
|
||||
let widths: Vec<i64> = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect();
|
||||
let border = "<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>";
|
||||
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
|
||||
for b in [
|
||||
border,
|
||||
"<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
] {
|
||||
s.push_str(b);
|
||||
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
|
||||
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
|
||||
}
|
||||
s.push_str("</w:tblBorders></w:tblPr><w:tblGrid>");
|
||||
for w in &widths {
|
||||
@@ -475,45 +474,149 @@ fn docx_table(t: &Table) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// A Word document (.docx, Office Open XML): Heading 1/2 and Normal paragraphs, images inline at
|
||||
/// their size on the page, page breaks between pages.
|
||||
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
// The first page's size, within what Word accepts (0.1 to 22 in; a long receipt is taller),
|
||||
// in twips. Margins are 1 in, less on small pages so text keeps room.
|
||||
/// Pages with rotated text, or text that actually overlaps an image or an oversized
|
||||
/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout.
|
||||
/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including
|
||||
/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables.
|
||||
fn positioned(page: &Page) -> bool {
|
||||
if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) {
|
||||
return true;
|
||||
}
|
||||
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
||||
let overlays = page
|
||||
.images
|
||||
.iter()
|
||||
.map(|im| (im.rect, None))
|
||||
.chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i))));
|
||||
// Bound the pairwise test on hostile pages (many overlays times many blocks): past the
|
||||
// budget, keep the default flowing layout.
|
||||
let mut budget: usize = 1_000_000;
|
||||
for (r, own) in overlays {
|
||||
for (i, b) in page.blocks.iter().enumerate() {
|
||||
if budget == 0 {
|
||||
return false;
|
||||
}
|
||||
budget -= 1;
|
||||
if Some(i) != own && overlaps(r, b.rect) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that
|
||||
/// only touch, or hostile non-finite coordinates, do not count.
|
||||
fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
let w = a[2].min(b[2]) - a[0].max(b[0]);
|
||||
let h = a[3].min(b[3]) - a[1].max(b[1]);
|
||||
w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5
|
||||
}
|
||||
|
||||
/// Bound every value written into a shape's style, including hostile PDF coordinates.
|
||||
fn layout_number(n: f64) -> f64 {
|
||||
if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String {
|
||||
// One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a
|
||||
// page-sized watermark cannot push the invoice footer onto another page.
|
||||
let mut s = String::from("<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr>");
|
||||
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
||||
for b in &page.blocks {
|
||||
*shape_id = shape_id.saturating_add(1);
|
||||
let rotation = layout_number(-b.rotation).rem_euclid(360.0);
|
||||
let (sin, cos) = rotation.to_radians().sin_cos();
|
||||
let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0));
|
||||
let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh };
|
||||
// Recover the unrotated width from the axis-aligned box. Use the larger axis so
|
||||
// quarter turns and 45-degree watermarks do not divide by (nearly) zero.
|
||||
let w = if b.rotation.abs() <= 0.1 {
|
||||
bw
|
||||
} else if cos.abs() >= sin.abs() {
|
||||
(bw - h * sin.abs()) / cos.abs()
|
||||
} else {
|
||||
(bh - h * cos.abs()) / sin.abs()
|
||||
};
|
||||
let w = layout_number(w).max(1.0);
|
||||
let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0));
|
||||
let z = if b.size >= 2.0 * body { -2 } else { 1 };
|
||||
let style = format!(
|
||||
"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page"
|
||||
);
|
||||
s.push_str(&format!(
|
||||
"<w:r><w:pict><v:rect id=\"Text{}\" style=\"{style}\" filled=\"f\" stroked=\"f\"><v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text:t\"><w:txbxContent><w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\"/></w:pPr>{}</w:p></w:txbxContent></v:textbox><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>",
|
||||
shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color)
|
||||
));
|
||||
}
|
||||
for im in &page.images {
|
||||
*shape_id = shape_id.saturating_add(1);
|
||||
let n = media.len().saturating_add(1);
|
||||
let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0));
|
||||
let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0));
|
||||
let rotation = layout_number(-im.rotation).rem_euclid(360.0);
|
||||
s.push_str(&format!("<w:r><w:pict><v:rect id=\"Image{shape_id}\" style=\"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page\" stroked=\"f\"><v:imagedata r:id=\"rIdImg{n}\"/><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>"));
|
||||
media.push((format!("image{n}.{}", im.ext), im));
|
||||
}
|
||||
s.push_str("</w:p>");
|
||||
s
|
||||
}
|
||||
|
||||
fn word_page_size(page: &Page) -> (i64, i64) {
|
||||
let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 };
|
||||
let (pw, ph) = pages.first().map_or((12240, 15840), |p| (twips(p.width), twips(p.height)));
|
||||
(twips(page.width), twips(page.height))
|
||||
}
|
||||
|
||||
fn section(page: &Page) -> String {
|
||||
let (pw, ph) = word_page_size(page);
|
||||
let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440));
|
||||
// Images are at most the text width, in points.
|
||||
let text_w = (pw - 2 * mx) as f64 / 20.0;
|
||||
format!(
|
||||
"<w:sectPr><w:type w:val=\"nextPage\"/><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr>",
|
||||
my / 2,
|
||||
my / 2
|
||||
)
|
||||
}
|
||||
|
||||
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
|
||||
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
|
||||
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
let mut body = String::new();
|
||||
let mut media: Vec<(String, &Image)> = Vec::new();
|
||||
// Word needs a paragraph between adjacent tables and after the last one in the body.
|
||||
let mut after_table = false;
|
||||
let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color);
|
||||
for it in items(pages) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
}
|
||||
body.push_str(&docx_table(t));
|
||||
}
|
||||
Item::Img(im) => {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, in EMU (12700 per point), at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64);
|
||||
body.push_str(&format!(
|
||||
let mut shape_id = 0usize;
|
||||
for (page_index, page) in pages.iter().enumerate() {
|
||||
let (pw, _) = word_page_size(page);
|
||||
let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0;
|
||||
if positioned(page) {
|
||||
body.push_str(&positioned_page(page, &mut media, &mut shape_id));
|
||||
after_table = false;
|
||||
} else {
|
||||
for it in items(std::slice::from_ref(page)) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
}
|
||||
body.push_str(&docx_table(t));
|
||||
}
|
||||
Item::Img(im) => {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, in EMU (12700 per point), at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64);
|
||||
body.push_str(&format!(
|
||||
"<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/><wp:docPr id=\"{n}\" name=\"Picture {n}\"/>\
|
||||
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
|
||||
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
|
||||
@@ -521,22 +624,33 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
<pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
|
||||
</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
|
||||
));
|
||||
media.push((name, im));
|
||||
media.push((name, im));
|
||||
}
|
||||
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
||||
}
|
||||
after_table = matches!(it, Item::Table(_));
|
||||
}
|
||||
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
||||
}
|
||||
after_table = matches!(it, Item::Table(_));
|
||||
}
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
// Section breaks carry this source page's size and create exactly one next page.
|
||||
// The last section must be a direct child of the body (OOXML 17.6.17).
|
||||
if page_index + 1 < pages.len() {
|
||||
body.push_str(&format!(
|
||||
"<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/>{}</w:pPr></w:p>",
|
||||
section(page)
|
||||
));
|
||||
} else {
|
||||
if after_table || page.blocks.is_empty() && page.images.is_empty() {
|
||||
body.push_str("<w:p><w:pPr><w:spacing w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr></w:p>");
|
||||
}
|
||||
body.push_str(§ion(page));
|
||||
}
|
||||
after_table = false;
|
||||
}
|
||||
let doc = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
|
||||
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
|
||||
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\"><w:body>{body}\
|
||||
<w:sectPr><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr></w:body></w:document>",
|
||||
my / 2,
|
||||
my / 2
|
||||
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\"><w:body>{body}\
|
||||
</w:body></w:document>"
|
||||
);
|
||||
let mut types = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
|
||||
@@ -698,6 +812,7 @@ mod tests {
|
||||
bold,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -712,6 +827,8 @@ mod tests {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
rect: [72.0, 450.0, 272.0, 600.0],
|
||||
size: [200.0, 150.0],
|
||||
rotation: 0.0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -741,6 +858,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_does_not_invent_borders_for_aligned_invoice_fields() {
|
||||
let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml");
|
||||
assert!(xml.contains("<w:tbl>"));
|
||||
assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}");
|
||||
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
|
||||
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
|
||||
// nothing overlaps: it must stay flowing paragraphs with the image inline.
|
||||
let xml = part(&docx(&[page()], "Report"), "word/document.xml");
|
||||
assert!(!xml.contains("position:absolute"), "nothing overlaps, so nothing floats: {xml}");
|
||||
assert!(xml.contains("<wp:inline>"), "the picture stays inline: {xml}");
|
||||
assert!(xml.contains("<w:pStyle w:val=\"Heading1\"/>"), "the title stays a heading: {xml}");
|
||||
let (title, body) = (xml.find(">Annual Report</w:t>"), xml.find(">Body text after the picture"));
|
||||
assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}");
|
||||
// The same page with the picture moved over the body text floats.
|
||||
let mut over = page();
|
||||
over.images[0].rect = [72.0, 380.0, 272.0, 530.0];
|
||||
let xml = part(&docx(&[over], "Report"), "word/document.xml");
|
||||
assert!(xml.contains("position:absolute"), "an image over text floats: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_overlap_does_not_consume_invoice_body_space() {
|
||||
let mut p = table_page();
|
||||
p.blocks.push(Block {
|
||||
text: "DRAFT".into(),
|
||||
rect: [100.0, 200.0, 500.0, 700.0],
|
||||
size: 100.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.8; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
p.images = page().images;
|
||||
let xml = part(&docx(&[p], "Invoice"), "word/document.xml");
|
||||
assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}");
|
||||
assert!(!xml.contains("<wp:inline>"), "images must not displace invoice text");
|
||||
assert!(!xml.contains("<w:tbl>"), "the source text boxes must keep their positions");
|
||||
assert_eq!(xml.matches(">DRAFT</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches("w:type=\"page\"").count(), 0);
|
||||
assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}");
|
||||
let watermark = xml.split(">DRAFT</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
|
||||
let image = xml.split("<v:imagedata ").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(image.contains("z-index:-1"), "foreground graphics cover the watermark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() {
|
||||
let mut p = table_page();
|
||||
p.blocks[0].rotation = 45.0;
|
||||
let mut second = p.clone();
|
||||
second.width = 400.0;
|
||||
second.height = 500.0;
|
||||
let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml");
|
||||
assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down");
|
||||
assert_eq!(xml.matches("<w:sectPr>").count(), 2);
|
||||
assert!(xml.contains("<w:pgSz w:w=\"8000\" w:h=\"10000\"/>"));
|
||||
assert_eq!(xml.matches("id=\"Text1\"").count(), 1);
|
||||
assert_eq!(xml.matches(">Meter</w:t>").count(), 2);
|
||||
}
|
||||
|
||||
/// One part of a package written by [`Zip`], inflated.
|
||||
fn part(zip: &[u8], name: &str) -> String {
|
||||
let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
@@ -771,6 +954,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
|
||||
assert!(xml.contains(">Total 4.50\tGBP</w:t>"), "{xml}");
|
||||
@@ -812,7 +996,15 @@ mod tests {
|
||||
#[test]
|
||||
fn rtf_escapes_and_sizes() {
|
||||
let mut p = page();
|
||||
p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true, color: [0.0; 3] });
|
||||
p.blocks.push(Block {
|
||||
text: "Café {x}".into(),
|
||||
rect: [72.0, 100.0, 200.0, 110.0],
|
||||
size: 10.0,
|
||||
bold: false,
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
let r = rtf(&[p]);
|
||||
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
|
||||
assert!(r.contains("\\fs48\\b Annual Report"));
|
||||
@@ -828,6 +1020,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -850,6 +1043,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
},
|
||||
],
|
||||
images: Vec::new(),
|
||||
@@ -893,7 +1087,7 @@ mod tests {
|
||||
.map(|i| {
|
||||
let (row, col) = (i / 2, i / 2 + i % 2);
|
||||
let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3] }
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
@@ -912,7 +1106,15 @@ mod tests {
|
||||
.flat_map(|row| {
|
||||
(0..n).map(move |col| {
|
||||
let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
|
||||
Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false, color: [0.0; 3] }
|
||||
Block {
|
||||
text: (col + 1).to_string(),
|
||||
rect: [x, y, x + 8.0, y + 8.0],
|
||||
size: 8.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
@@ -933,6 +1135,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
let p = Page {
|
||||
width: 612.0,
|
||||
|
||||
+310
-19
@@ -188,17 +188,58 @@ pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: H
|
||||
c.copy_value(dst, o, None)
|
||||
}
|
||||
|
||||
/// How colliding fully-qualified field names are resolved when pages are imported.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum FieldConflicts {
|
||||
/// The incoming field's own `/T` is renamed to the first free `Name-1`, `Name-2`… variant,
|
||||
/// so it stays its own field (the default; Combine and Insert from File use it).
|
||||
#[default]
|
||||
Rename,
|
||||
/// The older behaviour: a field that arrives with a taken name becomes one field with the
|
||||
/// one already there. Duplicate, Replace, Extract and Split always use it.
|
||||
Merge,
|
||||
}
|
||||
|
||||
/// What [`import_pages_with`] did beyond placing the pages.
|
||||
pub struct ImportOutcome {
|
||||
/// The new pages, in order.
|
||||
pub pages: Vec<ObjRef>,
|
||||
/// Every field renamed to keep its name separate: old fully-qualified name → new one.
|
||||
pub renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
|
||||
/// position `at`, resolving field-name collisions per `conflicts`. Returns the new page
|
||||
/// references in order and what the conflict resolution renamed.
|
||||
///
|
||||
/// Resources identical to ones already in `dst` (the same fonts, images, colour profiles) are
|
||||
/// shared rather than stored twice (see `dedupe`).
|
||||
pub fn import_pages_with(
|
||||
dst: &mut Document,
|
||||
src: &Document,
|
||||
src_pages: &[usize],
|
||||
at: usize,
|
||||
conflicts: FieldConflicts,
|
||||
) -> Result<ImportOutcome, OrganizeError> {
|
||||
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
|
||||
let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?;
|
||||
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(dst, &created, true);
|
||||
Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields })
|
||||
}
|
||||
|
||||
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
|
||||
/// position `at`. Returns the new page references in order.
|
||||
///
|
||||
/// Fields keep their names ([`FieldConflicts::Merge`]): this is what Duplicate and Replace
|
||||
/// Pages use, where the incoming fields are copies of the document's own and renaming them
|
||||
/// would silently change what the form exports. Combine and Insert from File, which bring in
|
||||
/// another document's fields, use [`import_pages_with`] and [`FieldConflicts::Rename`].
|
||||
///
|
||||
/// Resources identical to ones already in `dst` (the same fonts, images, colour profiles) are
|
||||
/// shared rather than stored twice (see `dedupe`).
|
||||
pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Vec<ObjRef>, OrganizeError> {
|
||||
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
|
||||
let pages = import_pages_mapped(dst, src, src_pages, at)?.pages;
|
||||
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(dst, &created, true);
|
||||
Ok(pages)
|
||||
import_pages_with(dst, src, src_pages, at, FieldConflicts::Merge).map(|o| o.pages)
|
||||
}
|
||||
|
||||
/// What `import_pages_mapped` copied.
|
||||
@@ -209,10 +250,18 @@ struct Imported {
|
||||
page_map: HashMap<ObjRef, ObjRef>,
|
||||
/// Every source object copied → its copy.
|
||||
objects: HashMap<ObjRef, ObjRef>,
|
||||
/// Fields renamed to keep their names separate (old → new fully-qualified name).
|
||||
renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// `import_pages`, also returning what was copied from where.
|
||||
fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Imported, OrganizeError> {
|
||||
fn import_pages_mapped(
|
||||
dst: &mut Document,
|
||||
src: &Document,
|
||||
src_pages: &[usize],
|
||||
at: usize,
|
||||
conflicts: FieldConflicts,
|
||||
) -> Result<Imported, OrganizeError> {
|
||||
let source = walk(src)?;
|
||||
if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) {
|
||||
return Err(OrganizeError::NoSuchPage(*bad));
|
||||
@@ -336,13 +385,17 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize],
|
||||
new_pages.push(new);
|
||||
}
|
||||
copier.fix_annotations(dst, &annot_pages)?;
|
||||
let renamed_fields = match conflicts {
|
||||
FieldConflicts::Rename => resolve_field_conflicts(dst, &copier.fields)?,
|
||||
FieldConflicts::Merge => Vec::new(),
|
||||
};
|
||||
register_fields(dst, &copier.fields)?;
|
||||
register_layers(dst, src, &copier.ocgs)?;
|
||||
let at = at.min(existing.len());
|
||||
let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect();
|
||||
existing.splice(at..at, inserted);
|
||||
rebuild(dst, &existing)?;
|
||||
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map })
|
||||
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map, renamed_fields })
|
||||
}
|
||||
|
||||
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
|
||||
@@ -444,6 +497,189 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names
|
||||
/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of
|
||||
/// blurring into the destination's field of the same name (fields of one fully-qualified name
|
||||
/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`…
|
||||
/// variant, so everything below it keeps its place in the tree. Returns what was renamed
|
||||
/// (old fully-qualified name → new one), in order.
|
||||
fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result<Vec<(String, String)>, OrganizeError> {
|
||||
let mut taken = taken_field_names(dst)?;
|
||||
let planned = plan_renames(dst, fields, &mut taken);
|
||||
let mut renamed = Vec::new();
|
||||
for (node, new_t, old, new) in planned {
|
||||
dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?;
|
||||
renamed.push((old, new));
|
||||
}
|
||||
Ok(renamed)
|
||||
}
|
||||
|
||||
/// What importing `src` into `dst` would rename, computed without changing either document:
|
||||
/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier
|
||||
/// field of `src` itself uses) is renamed to the first free variant, exactly as the import
|
||||
/// would. Unreadable structure previews as nothing: the import itself will say why it failed.
|
||||
pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> {
|
||||
let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() };
|
||||
let Ok(roots) = field_roots(src) else { return Vec::new() };
|
||||
plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect()
|
||||
}
|
||||
|
||||
/// What combining `sources`, in order, would rename in each one: the whole list is simulated
|
||||
/// with the same first-free-variant rule the combine uses, so a later file's preview sees the
|
||||
/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will
|
||||
/// say why it fails); non-colliding names still count as taken for the files after them.
|
||||
pub fn combine_field_conflicts(sources: &[&Document]) -> Vec<Vec<(String, String)>> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
sources
|
||||
.iter()
|
||||
.map(|src| match field_roots(src) {
|
||||
Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate
|
||||
/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same
|
||||
/// import collides with an earlier one), and a node already taken is renamed to the first free
|
||||
/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored
|
||||
/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name.
|
||||
/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe.
|
||||
fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet<String>) -> Vec<(ObjRef, String, String, String)> {
|
||||
let mut planned = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for &root in roots {
|
||||
plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned);
|
||||
}
|
||||
planned
|
||||
}
|
||||
|
||||
/// One node of [`plan_renames`].
|
||||
fn plan_renames_under(
|
||||
doc: &Document,
|
||||
node: ObjRef,
|
||||
prefix: &str,
|
||||
taken: &mut std::collections::HashSet<String>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
depth: u8,
|
||||
planned: &mut Vec<(ObjRef, String, String, String)>,
|
||||
) {
|
||||
if depth > 32 || !seen.insert(node) {
|
||||
return;
|
||||
}
|
||||
let Some(d) = doc.get(node).as_dict().cloned() else { return };
|
||||
let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
let fqn = qualified(prefix, &name);
|
||||
if taken.contains(&fqn) {
|
||||
let mut n = 1u32;
|
||||
let mut variant;
|
||||
while {
|
||||
variant = format!("{name}-{n}");
|
||||
taken.contains(&qualified(prefix, &variant))
|
||||
} {
|
||||
n += 1;
|
||||
}
|
||||
let new_fqn = qualified(prefix, &variant);
|
||||
planned.push((node, variant.clone(), fqn, new_fqn.clone()));
|
||||
taken.insert(new_fqn);
|
||||
name = variant;
|
||||
} else {
|
||||
taken.insert(fqn);
|
||||
}
|
||||
}
|
||||
let prefix = qualified_optional(prefix, &name);
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for kid in kids.iter().filter_map(|k| k.as_ref()) {
|
||||
plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned);
|
||||
}
|
||||
}
|
||||
|
||||
/// The field names in use in `dst`: every name in its form, and every name hanging only off a
|
||||
/// page's widgets — a field the form never lists still owns its name.
|
||||
fn taken_field_names(dst: &Document) -> Result<std::collections::HashSet<String>, OrganizeError> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for root in field_roots(dst)? {
|
||||
collect_names(dst, root, "", &mut seen, 0, &mut taken);
|
||||
}
|
||||
Ok(taken)
|
||||
}
|
||||
|
||||
/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and
|
||||
/// the fields behind widgets on pages (climbed to their root, for forms that never registered
|
||||
/// them).
|
||||
fn field_roots(doc: &Document) -> Result<Vec<ObjRef>, OrganizeError> {
|
||||
let mut roots: Vec<ObjRef> = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f)))
|
||||
.and_then(|f| f.as_dict().cloned())
|
||||
.and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l)))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|f| f.as_ref())
|
||||
.collect();
|
||||
for (page, _) in walk(doc)? {
|
||||
let Some(d) = doc.get(page).as_dict().cloned() else { continue };
|
||||
let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
for a in annots.iter() {
|
||||
let Some(w) = doc.resolve(a).as_dict().cloned() else { continue };
|
||||
if w.name(b"Subtype") != Some(b"Widget") {
|
||||
continue;
|
||||
}
|
||||
// Walk up to the field the widget belongs to, however deep it sits; a widget that
|
||||
// is its own field is its own root.
|
||||
let Some(mut root) = a.as_ref() else { continue };
|
||||
let mut up = 0;
|
||||
while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) {
|
||||
if up >= 32 || root == parent {
|
||||
break;
|
||||
}
|
||||
root = parent;
|
||||
up += 1;
|
||||
}
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
/// Record the fully-qualified names of one field tree. Cycle- and depth-safe.
|
||||
fn collect_names(
|
||||
dst: &Document,
|
||||
node: ObjRef,
|
||||
prefix: &str,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
depth: u8,
|
||||
out: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
if depth > 32 || !seen.insert(node) {
|
||||
return;
|
||||
}
|
||||
let Some(d) = dst.get(node).as_dict().cloned() else { return };
|
||||
let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
|
||||
let prefix = qualified_optional(prefix, &name);
|
||||
if !name.is_empty() {
|
||||
out.insert(prefix.clone());
|
||||
}
|
||||
let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for kid in kids.iter().filter_map(|k| k.as_ref()) {
|
||||
collect_names(dst, kid, &prefix, seen, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `prefix.name`, or `name` at the top level.
|
||||
fn qualified(prefix: &str, name: &str) -> String {
|
||||
if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }
|
||||
}
|
||||
|
||||
/// The prefix a node's children hang under: its own name, when it has one.
|
||||
fn qualified_optional(prefix: &str, name: &str) -> String {
|
||||
if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) }
|
||||
}
|
||||
|
||||
/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed).
|
||||
fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> {
|
||||
if fields.is_empty() {
|
||||
@@ -478,8 +714,9 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize
|
||||
/// source declares (PDF/X: its output intents and identification, #263).
|
||||
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
|
||||
let mut out = Document::new_empty();
|
||||
// One source: nothing to deduplicate.
|
||||
let imported = import_pages_mapped(&mut out, src, pages, 0)?;
|
||||
// One source: nothing to deduplicate. Its fields keep their names (a source with two
|
||||
// same-named field roots stays one field, as it was), so Extract and Split never rename.
|
||||
let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?;
|
||||
for key in crate::INFO_KEYS {
|
||||
if let Some(v) = crate::info(src, key) {
|
||||
crate::set_info(&mut out, key, &v)?;
|
||||
@@ -527,24 +764,43 @@ pub fn split(src: &Document, by: &SplitBy) -> Result<Vec<Document>, OrganizeErro
|
||||
split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::<Vec<_>>())).collect()
|
||||
}
|
||||
|
||||
/// What combining produced, beyond the document: the fields renamed to keep colliding names
|
||||
/// separate (old fully-qualified name → new one), in order across all sources.
|
||||
pub struct CombineOutcome {
|
||||
/// The combined document.
|
||||
pub doc: Document,
|
||||
/// Every field renamed while combining.
|
||||
pub renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Combine whole documents, in order, into a new document. Each source gets a top-level
|
||||
/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files.
|
||||
/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level
|
||||
/// attachments are carried over. When every source declares the same print standard (PDF/X
|
||||
/// with the same output intent), so does the result.
|
||||
pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError> {
|
||||
combine_with(sources, FieldConflicts::Rename).map(|o| o.doc)
|
||||
}
|
||||
|
||||
/// [`combine`] reporting what the conflict resolution renamed.
|
||||
pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
|
||||
let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect();
|
||||
combine_selected(&all)
|
||||
combine_selected_with(&all, conflicts)
|
||||
}
|
||||
|
||||
/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order;
|
||||
/// `None` for all of them). Bookmarks that point at pages left out lose their destination.
|
||||
pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result<Document, OrganizeError> {
|
||||
let mut combiner = Combiner::new();
|
||||
combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc)
|
||||
}
|
||||
|
||||
/// [`combine_selected`] reporting what the conflict resolution renamed.
|
||||
pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
|
||||
let mut combiner = Combiner::with_field_conflicts(conflicts);
|
||||
for (title, src, chosen) in sources {
|
||||
combiner.add(title, src, *chosen)?;
|
||||
}
|
||||
combiner.finish()
|
||||
combiner.finish_with_renames()
|
||||
}
|
||||
|
||||
/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`]
|
||||
@@ -560,6 +816,10 @@ pub struct Combiner {
|
||||
standard: Option<Standard>,
|
||||
/// Whether every source so far declares the first source's print standard.
|
||||
agreed: bool,
|
||||
/// How colliding field names are resolved as each source is added.
|
||||
field_conflicts: FieldConflicts,
|
||||
/// What adding sources renamed (old → new fully-qualified field name), in order.
|
||||
renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The first source's print standard (see [`Combiner`]).
|
||||
@@ -578,9 +838,27 @@ impl Default for Combiner {
|
||||
}
|
||||
|
||||
impl Combiner {
|
||||
/// A combine with no sources yet.
|
||||
/// A combine with no sources yet, renaming colliding field names as sources are added.
|
||||
pub fn new() -> Self {
|
||||
Self { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), standard: None, agreed: true }
|
||||
Self::with_field_conflicts(FieldConflicts::Rename)
|
||||
}
|
||||
|
||||
/// [`Combiner::new`] resolving colliding field names as `conflicts` says.
|
||||
pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self {
|
||||
Self {
|
||||
out: Document::new_empty(),
|
||||
marks: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
standard: None,
|
||||
agreed: true,
|
||||
field_conflicts,
|
||||
renamed_fields: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What adding sources has renamed so far (old → new fully-qualified field name).
|
||||
pub fn renamed_fields(&self) -> &[(String, String)] {
|
||||
&self.renamed_fields
|
||||
}
|
||||
|
||||
/// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far,
|
||||
@@ -597,7 +875,8 @@ impl Combiner {
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let at = crate::page_count(&self.out)?;
|
||||
let imported = import_pages_mapped(&mut self.out, src, &pages, at)?;
|
||||
let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?;
|
||||
self.renamed_fields.extend(imported.renamed_fields);
|
||||
if let Some(first) = imported.pages.first() {
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
self.marks.push(Mark { title: title.to_string(), page: *first, outline });
|
||||
@@ -617,7 +896,12 @@ impl Combiner {
|
||||
/// The combined document: the pages as added, the print standard of the first source when every
|
||||
/// source declares the same one, shared resources stored once, then the bookmarks and attachments.
|
||||
pub fn finish(self) -> Result<Document, OrganizeError> {
|
||||
let Combiner { mut out, marks, attachments, standard, agreed } = self;
|
||||
self.finish_with_renames().map(|o| o.doc)
|
||||
}
|
||||
|
||||
/// [`Combiner::finish`] reporting what the combine renamed.
|
||||
pub fn finish_with_renames(self) -> Result<CombineOutcome, OrganizeError> {
|
||||
let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self;
|
||||
if let Some(first) = standard.filter(|_| agreed) {
|
||||
crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?;
|
||||
}
|
||||
@@ -626,7 +910,7 @@ impl Combiner {
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(out)
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,6 +925,11 @@ pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>);
|
||||
/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its
|
||||
/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources).
|
||||
pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
|
||||
combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc)
|
||||
}
|
||||
|
||||
/// [`combine_grouped`] reporting what the conflict resolution renamed.
|
||||
pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
|
||||
// Each file's pages, in the order its runs list them, and each run's place in that list.
|
||||
struct File<'a> {
|
||||
title: &'a str,
|
||||
@@ -675,12 +964,14 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
|
||||
let mut marks = Vec::new();
|
||||
let mut attachments = Vec::new();
|
||||
let mut copies: Vec<Vec<ObjRef>> = Vec::with_capacity(files.len());
|
||||
let mut renamed_fields = Vec::new();
|
||||
let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap<ObjRef, ObjRef>)> = None;
|
||||
let mut agreed = true;
|
||||
for (_, file) in &files {
|
||||
let at = crate::page_count(&out)?;
|
||||
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at)?;
|
||||
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?;
|
||||
copies.push(imported.pages.clone());
|
||||
renamed_fields.extend(imported.renamed_fields);
|
||||
if let Some(first) = imported.pages.first() {
|
||||
marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) });
|
||||
}
|
||||
@@ -706,7 +997,7 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
|
||||
crate::dedupe::dedupe_resources(&mut out, &all, false);
|
||||
add_outline(&mut out, &marks)?;
|
||||
set_attachments(&mut out, attachments)?;
|
||||
Ok(out)
|
||||
Ok(CombineOutcome { doc: out, renamed_fields })
|
||||
}
|
||||
|
||||
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.
|
||||
|
||||
@@ -25,7 +25,9 @@ pub mod view;
|
||||
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{
|
||||
Combiner, Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges,
|
||||
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
|
||||
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, page_as_form, split,
|
||||
split_ranges,
|
||||
};
|
||||
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
|
||||
pub use outline::{
|
||||
|
||||
@@ -1726,3 +1726,365 @@ fn a_repeated_widget_joins_an_indirect_kids_array() {
|
||||
let kids = out.resolve(field.get(b"Kids").unwrap()).as_array().cloned().unwrap();
|
||||
assert_eq!(kids.len(), 3, "both original widgets and the repeat's own: {kids:?}");
|
||||
}
|
||||
|
||||
/// A one-page document whose form holds one text field named `name` (a widget that is its own
|
||||
/// field, the smallest form a page can carry).
|
||||
fn doc_with_field(name: &str) -> Document {
|
||||
doc_with_fields(&[name])
|
||||
}
|
||||
|
||||
/// A one-page document whose form holds one widget field per name, in order.
|
||||
fn doc_with_fields(names: &[&str]) -> Document {
|
||||
let first = 5; // object number of the first field
|
||||
let refs: Vec<String> = names.iter().enumerate().map(|(i, _)| format!("{} 0 R", first + i)).collect();
|
||||
let mut b: Vec<String> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", refs.join(" ")), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs.join(" ")), // 3
|
||||
"<< /Title (form) >>".into(), // 4
|
||||
];
|
||||
for name in names.iter() {
|
||||
b.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>")); // 5 + i
|
||||
}
|
||||
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
|
||||
}
|
||||
|
||||
/// The /T names of a document's /AcroForm /Fields, in order.
|
||||
fn form_field_names(doc: &Document) -> Vec<String> {
|
||||
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
form.get(b"Fields")
|
||||
.map(|f| doc.resolve(f))
|
||||
.and_then(|f| f.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
doc.resolve(f)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"T").cloned())
|
||||
.map(|t| doc.resolve(&t))
|
||||
.and_then(|t| t.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned()))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two documents whose forms both name a field Text1 cannot just be put together: fields of
|
||||
/// one fully-qualified name are one field, so the values would blur into it. The incoming
|
||||
/// field is renamed to the first free variant, Text1-1.
|
||||
#[test]
|
||||
fn import_renames_a_colliding_field_name() {
|
||||
let mut dst = doc_with_field("Text1");
|
||||
let src = doc_with_field("Text1");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"]);
|
||||
}
|
||||
|
||||
/// The rename takes the first free variant: with Text1 and Text1-1 already in the form, a
|
||||
/// colliding Text1 becomes Text1-2 (not Text1-1, which would collide again).
|
||||
#[test]
|
||||
fn import_renames_to_the_first_free_variant() {
|
||||
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
|
||||
let src = doc_with_field("Text1");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1", "Text1-2"]);
|
||||
}
|
||||
|
||||
/// A one-page document whose form holds one text field whose `/T` is a UTF-16BE hex string —
|
||||
/// the standard encoding for non-ASCII names — decoding to `name`.
|
||||
fn doc_with_field_utf16(name: &str) -> Document {
|
||||
let t: String = std::iter::once(0xFEFFu16).chain(name.encode_utf16()).map(|u| format!("{u:04X}")).collect();
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
|
||||
"<< /Title (form) >>".into(), // 4
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T <{t}> /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 5
|
||||
];
|
||||
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
|
||||
}
|
||||
|
||||
/// The decoded `/T` names of a document's /AcroForm /Fields, in order (whatever encoding each
|
||||
/// was written in).
|
||||
fn decoded_form_field_names(doc: &Document) -> Vec<String> {
|
||||
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
form.get(b"Fields")
|
||||
.map(|f| doc.resolve(f))
|
||||
.and_then(|f| f.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
doc.resolve(f)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"T").cloned())
|
||||
.map(|t| doc.resolve(&t))
|
||||
.and_then(|t| t.as_string().map(|s| s.to_text()))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A field's name is its text, not the bytes it was written in: a destination field named in
|
||||
/// PDFDocEncoding and an incoming UTF-16BE field that decode to the same name are one name,
|
||||
/// so the incoming field is renamed out of the way (not silently blurred into the other).
|
||||
#[test]
|
||||
fn import_renames_by_decoded_name_whatever_its_encoding() {
|
||||
let mut dst = doc_with_field("Text1");
|
||||
let src = doc_with_field_utf16("Text1");
|
||||
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
assert_eq!(decoded_form_field_names(&dst), ["Text1", "Text1-1"]);
|
||||
}
|
||||
|
||||
/// Renaming a non-ASCII name keeps it readable: the new `/T` stores the new name as text
|
||||
/// (UTF-16BE again), never as mojibake of the old bytes.
|
||||
#[test]
|
||||
fn a_renamed_non_ascii_name_stays_readable() {
|
||||
let mut dst = doc_with_field_utf16("Förm");
|
||||
let src = doc_with_field_utf16("Förm");
|
||||
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(out.renamed_fields, [("Förm".to_string(), "Förm-1".to_string())]);
|
||||
assert_eq!(decoded_form_field_names(&dst), ["Förm", "Förm-1"]);
|
||||
}
|
||||
|
||||
/// A one-page document whose form holds one field tree: a root field `root` with one child
|
||||
/// field `kid` (a merged field-and-widget, as page tools make them).
|
||||
fn doc_with_tree(root: &str, kid: &str) -> Document {
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), // 3
|
||||
"<< /Title (form) >>".into(), // 4
|
||||
format!("<< /FT /Tx /T ({root}) /Kids [6 0 R] >>"), // 5
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({kid}) /Parent 5 0 R /Rect [60 60 200 80] /P 3 0 R >>"), // 6
|
||||
];
|
||||
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
|
||||
}
|
||||
|
||||
/// A field's name is its place in the tree, not just its own `/T`: an incoming field called
|
||||
/// x.b collides with the child b of the destination's field x, and is renamed out of the way.
|
||||
#[test]
|
||||
fn import_renames_a_nested_field_whose_full_name_collides() {
|
||||
let mut dst = doc_with_tree("x", "b");
|
||||
let src = doc_with_field("x.b");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["x", "x.b-1"]);
|
||||
}
|
||||
|
||||
/// The collision may sit below the top level: the destination holds a field literally named
|
||||
/// a.b, and the incoming tree a ▸ b walks into it. Only the colliding child is renamed — the
|
||||
/// incoming root a never collided, so it keeps its name.
|
||||
#[test]
|
||||
fn import_renames_the_colliding_child_not_the_whole_tree() {
|
||||
let mut dst = doc_with_field("a.b");
|
||||
let src = doc_with_tree("a", "b");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["a.b", "a"], "the incoming root keeps its name");
|
||||
// The incoming tree is the second /Fields entry; its child was renamed.
|
||||
let fields = catalog(&dst)
|
||||
.get(b"AcroForm")
|
||||
.map(|f| dst.resolve(f))
|
||||
.and_then(|f| f.as_dict().cloned())
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.map(|l| dst.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap();
|
||||
let kid = dst.resolve(&fields[1]).as_dict().unwrap().get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
|
||||
let kid_t = dst.resolve(&kid[0]).as_dict().unwrap().get(b"T").cloned().unwrap();
|
||||
let resolved = dst.resolve(&kid_t);
|
||||
let name = resolved.as_string().unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&name.bytes), "b-1", "the child's /T is renamed, the FQN follows the root");
|
||||
}
|
||||
|
||||
/// A field name is taken even when it hangs only off a page's annotations: a widget that is
|
||||
/// its own field, never registered in `/AcroForm /Fields`, still names a field, and an import
|
||||
/// must not silently blur into it.
|
||||
#[test]
|
||||
fn import_renames_away_from_an_orphaned_widgets_name() {
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 (no /AcroForm)
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
|
||||
"<< /Title (orphan) >>".into(), // 4
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Text1) /V (x) /Rect [60 60 200 80] /P 3 0 R >>".into(), // 5
|
||||
];
|
||||
let mut dst = open(build(&b, "/Root 1 0 R /Info 4 0 R"));
|
||||
let src = doc_with_field("Text1");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Text1-1"], "the orphaned Text1 must count as taken");
|
||||
}
|
||||
|
||||
/// Callers can keep the older behaviour — an incoming field of a taken name becomes one field
|
||||
/// with the one already there — by asking to merge. Nothing is renamed.
|
||||
#[test]
|
||||
fn import_can_merge_into_same_named_fields_instead() {
|
||||
let mut dst = doc_with_field("Text1");
|
||||
let src = doc_with_field("Text1");
|
||||
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Merge).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Text1", "Text1"]);
|
||||
assert!(outcome.renamed_fields.is_empty(), "merge renames nothing");
|
||||
}
|
||||
|
||||
/// Duplicating a page copies the document's own fields: the copies keep their names (one field
|
||||
/// shown twice, as before field-conflict renaming existed) instead of silently becoming Text1-1.
|
||||
#[test]
|
||||
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
|
||||
let mut doc = doc_with_field("Text1");
|
||||
crate::duplicate_pages(&mut doc, &[0]).unwrap();
|
||||
assert_eq!(crate::page_count(&doc).unwrap(), 2);
|
||||
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
|
||||
}
|
||||
|
||||
/// Replace Pages keeps the field names too.
|
||||
#[test]
|
||||
fn replacing_a_page_renames_no_fields() {
|
||||
let mut doc = doc_with_field("Text1");
|
||||
crate::replace_pages(&mut doc, &[0], &doc_with_field("Text1"), &[0]).unwrap();
|
||||
assert!(form_field_names(&doc).iter().all(|n| n == "Text1"), "{:?}", form_field_names(&doc));
|
||||
}
|
||||
|
||||
/// Extract and Split copy one document: two same-named field roots (malformed but common) stay
|
||||
/// one field by name rather than being split apart as Name and Name-1.
|
||||
#[test]
|
||||
fn extract_and_split_keep_same_named_fields_together() {
|
||||
let src = doc_with_fields(&["Text1", "Text1"]);
|
||||
let out = extract_pages(&src, &[0]).unwrap();
|
||||
assert_eq!(form_field_names(&out), ["Text1", "Text1"]);
|
||||
for part in split(&src, &SplitBy::PageCount(1)).unwrap() {
|
||||
assert_eq!(form_field_names(&part), ["Text1", "Text1"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An import says what it renamed, so callers can tell the user — and their scripts — that a
|
||||
/// field they knew by one name now goes by another.
|
||||
#[test]
|
||||
fn import_reports_the_fields_it_renamed() {
|
||||
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
|
||||
let src = doc_with_field("Text1");
|
||||
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(outcome.renamed_fields, [("Text1".to_string(), "Text1-2".to_string())]);
|
||||
assert_eq!(outcome.pages.len(), 1);
|
||||
}
|
||||
|
||||
/// A renamed tree survives a save and reopen: the parent keeps its new name, the child still
|
||||
/// hangs under it with its own name unchanged, and an independent parser reads the same form.
|
||||
#[test]
|
||||
fn a_renamed_field_tree_survives_a_save_and_reopen() {
|
||||
let mut dst = doc_with_tree("a", "b");
|
||||
let src = doc_with_tree("a", "b");
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
let out = full_roundtrip(&dst);
|
||||
assert_eq!(form_field_names(&out), ["a", "a-1"], "the incoming root was renamed");
|
||||
let fields = catalog(&out)
|
||||
.get(b"AcroForm")
|
||||
.map(|f| out.resolve(f))
|
||||
.and_then(|f| f.as_dict().cloned())
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.map(|l| out.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap();
|
||||
let root = out.resolve(&fields[1]).as_dict().cloned().unwrap();
|
||||
let kids = root.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
|
||||
let kid = out.resolve(&kids[0]).as_dict().cloned().unwrap();
|
||||
assert_eq!(kid.reference(b"Parent"), fields[1].as_ref(), "the child still hangs under the renamed root");
|
||||
let kid_t = kid.get(b"T").cloned().unwrap();
|
||||
let resolved = out.resolve(&kid_t);
|
||||
assert_eq!(String::from_utf8_lossy(&resolved.as_string().unwrap().bytes), "b", "the child keeps its own name");
|
||||
}
|
||||
|
||||
/// The pre-scan says what an import of `src` into `dst` would rename, without changing
|
||||
/// anything — the Combine tab shows it before anything is combined.
|
||||
#[test]
|
||||
fn field_name_conflicts_previews_what_an_import_renames() {
|
||||
let dst = doc_with_fields(&["Text1", "Text1-1"]);
|
||||
let src = doc_with_field("Text1");
|
||||
assert_eq!(field_name_conflicts(&dst, &src), [("Text1".to_string(), "Text1-2".to_string())]);
|
||||
let apart = doc_with_field("Signature1");
|
||||
assert!(field_name_conflicts(&dst, &apart).is_empty(), "nothing collides, nothing to preview");
|
||||
}
|
||||
|
||||
/// The collision is not only with the destination: two incoming fields of one name would blur
|
||||
/// into each other just the same, so the second is renamed too.
|
||||
#[test]
|
||||
fn import_renames_a_field_colliding_with_another_incoming_field() {
|
||||
let mut dst = doc_with_field("Other");
|
||||
let src = doc_with_fields(&["Text1", "Text1"]);
|
||||
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]);
|
||||
}
|
||||
|
||||
/// A page listed twice shows its widget on both copies — also when the field was renamed: both
|
||||
/// pages' widgets belong to the one renamed field, and no second Text1 appears.
|
||||
#[test]
|
||||
fn a_repeated_page_joins_the_renamed_field() {
|
||||
let mut dst = doc_with_field("Text1");
|
||||
let src = doc_with_field("Text1");
|
||||
import_pages_with(&mut dst, &src, &[0, 0], 2, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"], "one field, renamed once");
|
||||
let fields = catalog(&dst)
|
||||
.get(b"AcroForm")
|
||||
.map(|f| dst.resolve(f))
|
||||
.and_then(|f| f.as_dict().cloned())
|
||||
.unwrap()
|
||||
.get(b"Fields")
|
||||
.map(|l| dst.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap();
|
||||
let renamed = fields[1].clone();
|
||||
for page in [1, 2] {
|
||||
let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field");
|
||||
}
|
||||
}
|
||||
|
||||
/// Combining forms keeps same-named fields separate — the second file's colliding field is
|
||||
/// renamed — and the combine says what it renamed.
|
||||
#[test]
|
||||
fn combine_renames_colliding_field_names_and_reports_them() {
|
||||
let a = doc_with_field("Text1");
|
||||
let b = doc_with_field("Text1");
|
||||
let out = combine_selected_with(&[("a", &a as &Document, None), ("b", &b, None)], FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
|
||||
let merged = combine_selected_with(&[("a", &a, None), ("b", &b, None)], FieldConflicts::Merge).unwrap();
|
||||
assert!(merged.renamed_fields.is_empty());
|
||||
assert_eq!(form_field_names(&merged.doc), ["Text1", "Text1"], "merge keeps the older behaviour");
|
||||
}
|
||||
|
||||
/// The grouped combine (a file's pages split around others') renames colliding names between
|
||||
/// files just the same, while a file combined with itself once keeps its fields whole.
|
||||
#[test]
|
||||
fn combine_grouped_renames_colliding_field_names() {
|
||||
let a = doc_with_field("Text1");
|
||||
let b = doc_with_field("Text1");
|
||||
let runs: Vec<Run<'_>> = vec![(0, "a", &a, None), (1, "b", &b, None)];
|
||||
let out = combine_grouped_with(&runs, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
|
||||
let split: Vec<Run<'_>> = vec![(0, "a", &a, Some(&[0])), (0, "a", &a, None), (1, "b", &b, None)];
|
||||
let once = combine_grouped_with(&split, FieldConflicts::Rename).unwrap();
|
||||
assert_eq!(
|
||||
once.renamed_fields,
|
||||
[("Text1".to_string(), "Text1-1".to_string())],
|
||||
"the grouped file is copied once; only b's colliding field renames"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pre-flight scan over a whole combine list: what each file's fields would be renamed,
|
||||
/// in order, exactly as the combine would rename them — the Combine tab shows this before
|
||||
/// anything is combined.
|
||||
#[test]
|
||||
fn combine_field_conflicts_previews_each_file_in_order() {
|
||||
let a = doc_with_field("Text1");
|
||||
let b = doc_with_field("Text1");
|
||||
let c = doc_with_fields(&["Text1", "Other"]);
|
||||
let preview = combine_field_conflicts(&[&a, &b, &c]);
|
||||
assert_eq!(preview[0], [], "the first file collides with nothing");
|
||||
assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename");
|
||||
}
|
||||
|
||||
+290
-33
@@ -10,8 +10,9 @@
|
||||
//! catalog), whose base direction is right to left.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use unicode_bidi::{BidiInfo, Level, ParagraphInfo};
|
||||
|
||||
/// `text` rearranged for an egui call that lays out one line. Text without right-to-left
|
||||
/// characters comes back borrowed and unchanged.
|
||||
@@ -32,18 +33,170 @@ pub fn visual(text: &str) -> Cow<'_, str> {
|
||||
fn push_visual(out: &mut String, text: &str, base: Level) {
|
||||
let info = BidiInfo::new(text, Some(base));
|
||||
for para in &info.paragraphs {
|
||||
let (levels, runs) = info.visual_runs(para, para.range.clone());
|
||||
for run in runs {
|
||||
let rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||||
let Some(piece) = text.get(run) else { continue };
|
||||
if rtl {
|
||||
push_rtl_run(out, piece);
|
||||
} else {
|
||||
out.extend(piece.chars().filter(|c| !is_bidi_control(*c)));
|
||||
}
|
||||
push_line(out, text, &info, para, para.range.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui
|
||||
/// needs it, appended to `out`.
|
||||
fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: Range<usize>) {
|
||||
let (levels, runs) = info.visual_runs(para, line);
|
||||
// First the true display order, every character left to right as it appears on screen.
|
||||
let mut shown = String::with_capacity(text.len());
|
||||
for run in runs {
|
||||
let rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||||
let Some(piece) = text.get(run) else { continue };
|
||||
let chars = piece.chars().filter(|c| !is_bidi_control(*c));
|
||||
if rtl {
|
||||
shown.extend(chars.rev().map(mirrored));
|
||||
} else {
|
||||
shown.extend(chars);
|
||||
}
|
||||
}
|
||||
push_for_shaper(out, &shown);
|
||||
}
|
||||
|
||||
/// `shown` (in screen order) as egui must get it: egui hands each stretch of right-to-left
|
||||
/// script to the shaper as one run, which lays it out right to left (and joins its letters), so
|
||||
/// each such stretch goes back to logical order. What lies between them (spaces, punctuation,
|
||||
/// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include
|
||||
/// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right.
|
||||
fn push_for_shaper(out: &mut String, shown: &str) {
|
||||
let mut stretch: Vec<char> = Vec::new();
|
||||
for c in shown.chars() {
|
||||
if is_rtl_script(c) || is_joiner(c) {
|
||||
stretch.push(c);
|
||||
} else {
|
||||
out.extend(stretch.drain(..).rev());
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.extend(stretch.drain(..).rev());
|
||||
}
|
||||
|
||||
/// One line of wrapped text (see [`wrap`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Line {
|
||||
/// The line in display order, for one egui call that lays out one line.
|
||||
pub text: String,
|
||||
/// The paragraph reads right to left: the line belongs against the right edge.
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
/// Whether `text` holds any right-to-left script, so [`wrap`] or [`visual_auto`] is needed.
|
||||
pub fn has_rtl(text: &str) -> bool {
|
||||
text.chars().any(is_rtl_script)
|
||||
}
|
||||
|
||||
/// Multi-line text with right-to-left script (a comment in Persian, Arabic, Hebrew), broken
|
||||
/// into lines that `fits` accepts (it measures a line in logical order) and each put in display
|
||||
/// order by the Unicode bidirectional algorithm (UAX #9).
|
||||
///
|
||||
/// Every paragraph takes its direction from its first strong character, as `dir="auto"` does,
|
||||
/// so a Persian comment reads right to left with its Latin words and numbers left to right
|
||||
/// inside it. Lines break between words, in reading order, and a word wider than a line
|
||||
/// between characters. Use it for painting; copying and accessibility keep the logical text.
|
||||
pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec<Line> {
|
||||
let info = BidiInfo::new(text, None);
|
||||
let mut lines = Vec::new();
|
||||
for para in &info.paragraphs {
|
||||
let whole = text.get(para.range.clone()).unwrap_or_default();
|
||||
// unicode-bidi ends a paragraph at the `\r` of `\r\n`; the `\n` is no new paragraph.
|
||||
if whole == "\n" && text.get(..para.range.start).is_some_and(|before| before.ends_with('\r')) {
|
||||
continue;
|
||||
}
|
||||
let content = para.range.start..para.range.start.saturating_add(whole.trim_end_matches(is_paragraph_separator).len());
|
||||
let rtl = para.level.is_rtl();
|
||||
for line in break_lines(text, content, &mut fits) {
|
||||
let mut out = String::new();
|
||||
// unicode-bidi indexes the first level of a line, so an empty one would panic.
|
||||
if !line.is_empty() {
|
||||
push_line(&mut out, text, &info, para, line);
|
||||
}
|
||||
lines.push(Line { text: out, rtl });
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// One line of `text` in display order, its direction from its first strong character (an
|
||||
/// author's name). Text without right-to-left characters comes back borrowed and unchanged.
|
||||
pub fn visual_auto(text: &str) -> Cow<'_, str> {
|
||||
if !has_rtl(text) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let lines: Vec<String> = wrap(text, |_| true).into_iter().map(|l| l.text).collect();
|
||||
Cow::Owned(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// The byte ranges of the lines `range` of `text` breaks into: as many words as `fits` accepts
|
||||
/// per line, a word too wide for any line split between characters. Always at least one line.
|
||||
fn break_lines(text: &str, range: Range<usize>, fits: &mut impl FnMut(&str) -> bool) -> Vec<Range<usize>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current: Option<Range<usize>> = None;
|
||||
for word in words(text, range.clone()) {
|
||||
if let Some(line) = current.as_mut() {
|
||||
if fits(text.get(line.start..word.end).unwrap_or_default()) {
|
||||
line.end = word.end;
|
||||
continue;
|
||||
}
|
||||
lines.push(line.clone());
|
||||
}
|
||||
// The word starts a line.
|
||||
let mut start = word.start;
|
||||
loop {
|
||||
let end = fitting_prefix(text, start..word.end, fits);
|
||||
if end >= word.end {
|
||||
current = Some(start..word.end);
|
||||
break;
|
||||
}
|
||||
lines.push(start..end);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
lines.push(current.unwrap_or(range.start..range.start));
|
||||
lines
|
||||
}
|
||||
|
||||
/// The end of the longest start of `range` that `fits`, at least one character.
|
||||
fn fitting_prefix(text: &str, range: Range<usize>, fits: &mut impl FnMut(&str) -> bool) -> usize {
|
||||
let piece = text.get(range.clone()).unwrap_or_default();
|
||||
let mut end = range.end;
|
||||
for (i, (at, c)) in piece.char_indices().enumerate() {
|
||||
let next = range.start.saturating_add(at).saturating_add(c.len_utf8());
|
||||
if i > 0 && !fits(text.get(range.start..next).unwrap_or_default()) {
|
||||
end = range.start.saturating_add(at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// The byte ranges of the whitespace-separated words of `range` in `text`.
|
||||
fn words(text: &str, range: Range<usize>) -> Vec<Range<usize>> {
|
||||
let mut words = Vec::new();
|
||||
let mut start = None;
|
||||
for (at, c) in text.get(range.clone()).unwrap_or_default().char_indices() {
|
||||
let at = range.start.saturating_add(at);
|
||||
match (c.is_whitespace(), start) {
|
||||
(true, Some(s)) => {
|
||||
words.push(s..at);
|
||||
start = None;
|
||||
}
|
||||
(false, None) => start = Some(at),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
words.push(s..range.end);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// Characters that end a paragraph (bidi class B).
|
||||
fn is_paragraph_separator(c: char) -> bool {
|
||||
matches!(c, '\n' | '\r' | '\u{1C}'..='\u{1E}' | '\u{85}' | '\u{2029}')
|
||||
}
|
||||
|
||||
/// A label of a right-to-left interface language, in logical order, rearranged for egui: each
|
||||
@@ -159,34 +312,18 @@ fn is_modifier_key(c: char) -> bool {
|
||||
matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}')
|
||||
}
|
||||
|
||||
/// One right-to-left run, in display order. Stretches of right-to-left script stay in logical
|
||||
/// order (egui gives each to the shaper as one face run, which reverses and joins it); what
|
||||
/// lies between them (spaces, punctuation: drawn by the Latin face, left to right) is reversed
|
||||
/// here, with brackets mirrored.
|
||||
fn push_rtl_run(out: &mut String, run: &str) {
|
||||
let mut pieces: Vec<(bool, String)> = Vec::new();
|
||||
for c in run.chars().filter(|c| !is_bidi_control(*c)) {
|
||||
let script = is_rtl_script(c);
|
||||
match pieces.last_mut() {
|
||||
Some((kind, piece)) if *kind == script => piece.push(c),
|
||||
_ => pieces.push((script, c.to_string())),
|
||||
}
|
||||
}
|
||||
for (script, piece) in pieces.iter().rev() {
|
||||
if *script {
|
||||
out.push_str(piece);
|
||||
} else {
|
||||
out.extend(piece.chars().rev().map(mirrored));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters of the right-to-left scripts' blocks: letters, marks and the scripts' own
|
||||
/// punctuation, all drawn by the script's face.
|
||||
fn is_rtl_script(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFC | 0x1_0800..=0x1_0FFF | 0x1_E800..=0x1_EFFF)
|
||||
}
|
||||
|
||||
/// The zero-width (non-)joiners inside a word (Persian `میروم`): egui shapes them with the
|
||||
/// letter before them.
|
||||
fn is_joiner(c: char) -> bool {
|
||||
matches!(c, '\u{200C}' | '\u{200D}')
|
||||
}
|
||||
|
||||
/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn.
|
||||
fn is_bidi_control(c: char) -> bool {
|
||||
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
||||
@@ -324,6 +461,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wrap` with a width of `n` characters.
|
||||
fn wrap_at(text: &str, n: usize) -> Vec<Line> {
|
||||
wrap(text, |s| s.chars().count() <= n)
|
||||
}
|
||||
|
||||
fn rtl(text: &str) -> Line {
|
||||
Line { text: text.to_owned(), rtl: true }
|
||||
}
|
||||
|
||||
fn ltr(text: &str) -> Line {
|
||||
Line { text: text.to_owned(), rtl: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_paragraph_reads_right_to_left() {
|
||||
// "I go to school": the words come out in reverse for egui's left-to-right placement.
|
||||
assert_eq!(wrap_at("من به مدرسه میروم", 80), [rtl("میروم مدرسه به من")]);
|
||||
// The zero-width non-joiner keeps a word whole, so the shaper gets it as one run.
|
||||
assert_eq!(wrap_at("کتابها", 80), [rtl("کتابها")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_lines_break_in_reading_order() {
|
||||
// The first words go on the first line, each line reordered on its own.
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("اثنين واحد"), rtl("أربعة ثلاثة")]);
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("أربعة ثلاثة اثنين واحد")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_direction_comes_from_the_first_strong_character() {
|
||||
// Latin first: a left-to-right paragraph with an Arabic phrase inside it.
|
||||
assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See اثنين واحد here")]);
|
||||
// Persian first: right to left, the Latin words and the number left to right inside it.
|
||||
assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("است 2 نسخه PDF فایل")]);
|
||||
assert_eq!(wrap_at("سلام Hello World", 80), [rtl("Hello World سلام")]);
|
||||
// Leading neutrals and numbers don't decide it.
|
||||
assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("اثنين واحد .12")]);
|
||||
// Each paragraph finds its own direction.
|
||||
assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("اثنين واحد"), ltr("OK then"), ltr(""), rtl("עולם שלום")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_punctuation_and_brackets() {
|
||||
// The sentence's full stop ends up on the left, brackets flip.
|
||||
assert_eq!(wrap_at("سلام دنیا.", 80), [rtl(".دنیا سلام")]);
|
||||
assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("ثلاثة (اثنين) واحد")]);
|
||||
assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("است؟ آماده آیا")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persian_digits_go_to_the_shaper_reversed() {
|
||||
// Persian and Arabic-Indic digits are Arabic script, which egui's shaper lays out right to
|
||||
// left; they read left to right, so they are handed over reversed.
|
||||
assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("شمسی ۲۰۴۱ سال")]);
|
||||
assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("٢١ صفحه")]);
|
||||
// Glued to a word, number and word swap places within one shaped run.
|
||||
assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۲۱")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_handles_odd_widths_and_input() {
|
||||
assert_eq!(wrap_at("", 10), Vec::<Line>::new());
|
||||
// A word wider than the line is broken between characters.
|
||||
assert_eq!(wrap_at("abcdefghij واحد", 4), [ltr("abcd"), ltr("efgh"), ltr("ij"), ltr("واحد")]);
|
||||
// Extra spaces between words don't make empty lines; \r\n is one break.
|
||||
assert_eq!(wrap_at("واحد اثنين\r\nب", 5), [rtl("واحد"), rtl("اثنين"), rtl("ب")]);
|
||||
// Nothing fits: one character per line, never an endless loop.
|
||||
assert_eq!(wrap("واحد", |_| false).len(), 4);
|
||||
let long = "ا ب ۱ (ج) ".repeat(5_000);
|
||||
for s in ["\u{202E}ا\u{202C}b", "\u{064B}", "\n\n", "\u{2029}ا", long.as_str()] {
|
||||
let lines = wrap_at(s, 7);
|
||||
let shown: usize = lines.iter().map(|l| l.text.chars().count()).sum();
|
||||
assert!(shown <= s.chars().count(), "{:?}", s.chars().take(12).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
/// The characters egui puts on screen for each line of `text`, left to right, zero-width
|
||||
/// ones left out, with the interface fonts.
|
||||
fn on_screen(text: &str) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions());
|
||||
let mut shown = Vec::new();
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for line in wrap(text, |_| true) {
|
||||
let galley = ui.painter().layout_no_wrap(line.text, egui::FontId::proportional(13.0), egui::Color32::WHITE);
|
||||
let glyphs = galley.rows.iter().flat_map(|row| row.glyphs.iter());
|
||||
shown.push(glyphs.filter(|g| g.advance_width > 0.0).map(|g| g.chr).collect());
|
||||
}
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
shown
|
||||
}
|
||||
|
||||
fn rev(s: &str) -> String {
|
||||
s.chars().rev().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_draws_lines_in_reading_order() {
|
||||
// Only with an Arabic-script face (the craft-fonts build input, as in CI); without one
|
||||
// egui has no shaper run to check.
|
||||
if pdfcraft_fonts::ui_arabic_fonts().is_empty() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(on_screen("من به مدرسه می\u{200C}روم"), [rev("من به مدرسه میروم")]);
|
||||
assert_eq!(on_screen("فایل PDF نسخه 2 است"), [format!("{} 2 {} PDF {}", rev("است"), rev("نسخه"), rev("فایل"))]);
|
||||
assert_eq!(on_screen("سال ۱۴۰۲ شمسی"), [format!("{} ۱۴۰۲ {}", rev("شمسی"), rev("سال"))]);
|
||||
assert_eq!(on_screen("ب۱۲"), ["۱۲ب"]);
|
||||
assert_eq!(on_screen("See واحد اثنين here"), [format!("See {} {} here", rev("اثنين"), rev("واحد"))]);
|
||||
assert_eq!(on_screen("واحد (اثنين)."), [format!(".({}) {}", rev("اثنين"), rev("واحد"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_with_its_own_direction() {
|
||||
assert!(matches!(visual_auto("Ada Lovelace"), Cow::Borrowed("Ada Lovelace")));
|
||||
assert_eq!(visual_auto("علی رضایی"), "رضایی علی");
|
||||
assert_eq!(visual_auto("علی 2"), "2 علی");
|
||||
assert_eq!(visual_auto("Team علی رضایی"), "Team رضایی علی");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_input_never_panics() {
|
||||
let long = "ا ب ".repeat(20_000);
|
||||
|
||||
@@ -3726,9 +3726,9 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.shadow(egui::Shadow { offset: [0, 2], blur: 10, spread: 0, color: Color32::from_black_alpha(if t.dark() { 80 } else { 22 }) })
|
||||
.inner_margin(egui::Margin::same(4))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) })
|
||||
.inner_margin(egui::Margin::same(6))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.vertical(|ui| {
|
||||
@@ -3747,7 +3747,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
// A small corner triangle marks the flyout.
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { Color32::WHITE } else { t.text_muted }, Stroke::NONE));
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
let open = (resp.clicked() && active) || resp.secondary_clicked();
|
||||
if resp.clicked() && !active {
|
||||
app.execute(current.command());
|
||||
@@ -3760,14 +3760,14 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
for tool in comments::GROUPS[g] {
|
||||
let on = app.quick_tool == QuickTool::Comment(*tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
if click.hovered() {
|
||||
ui.painter().rect_filled(row, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(row.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
row.left_center() + vec2(34.0, 0.0),
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
@@ -3776,13 +3776,13 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(row.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = click.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
@@ -3809,7 +3809,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
tri.to_vec(),
|
||||
if current_fill.is_some() { Color32::WHITE } else { t.text_muted },
|
||||
if current_fill.is_some() { t.accent } else { t.text_faint },
|
||||
Stroke::NONE,
|
||||
));
|
||||
if resp.clicked() && current_fill.is_none() {
|
||||
@@ -3827,14 +3827,14 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
}
|
||||
let on = current_fill == Some(tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
if click.hovered() {
|
||||
ui.painter().rect_filled(row, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(row.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
row.left_center() + vec2(34.0, 0.0),
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
@@ -3843,12 +3843,13 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(row.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
|
||||
@@ -307,17 +307,20 @@ fn tab(
|
||||
let label = crate::bidi::visual(&label).into_owned();
|
||||
let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x);
|
||||
let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap));
|
||||
// Full strip height so the active tab sits on the mode bar instead of floating in the title bar.
|
||||
let height = ui.available_height().max(height);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
|
||||
let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
|
||||
let press = theme::Press::track(ui, resp);
|
||||
let bg = if active {
|
||||
t.chrome
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
press.fill(t.chrome, t.chrome.lerp_to_gamma(t.hover, 0.65), t.hover)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
press.fill(Color32::TRANSPARENT, t.hover, t.pressed)
|
||||
};
|
||||
ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg);
|
||||
if bg.a() > 0 {
|
||||
ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg);
|
||||
}
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(rect.min + vec2(6.0, (height - 16.0) / 2.0), vec2(16.0, 16.0)),
|
||||
@@ -329,23 +332,26 @@ fn tab(
|
||||
});
|
||||
let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0));
|
||||
let x = ui.interact(x_rect, id.with("close"), Sense::click());
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x_rect, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
let x_press = theme::Press::track(ui, &x);
|
||||
x_press.wash(ui, x_rect, 4, false);
|
||||
// Unsaved changes: a dot where the close button sits, until the tab is hovered.
|
||||
if dirty && !resp.hovered() && !x.hovered() {
|
||||
ui.painter().circle_filled(x_rect.center(), 4.0, if active { t.text } else { t.text_muted });
|
||||
} else if active || resp.hovered() || x.hovered() {
|
||||
icons::paint(ui, x_rect, "x", 13.0, t.text_muted);
|
||||
icons::paint(ui, x_rect.translate(x_press.offset()), "x", 13.0, t.text_muted);
|
||||
}
|
||||
if x.clicked() {
|
||||
if theme::hand(x).clicked() {
|
||||
*close = Some(index);
|
||||
}
|
||||
let shown = match file {
|
||||
Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))),
|
||||
None => crate::bidi::visual(name),
|
||||
};
|
||||
resp.on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) } else { shown.into_owned() })
|
||||
theme::hand(resp).on_hover_text(if dirty {
|
||||
crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())])
|
||||
} else {
|
||||
shown.into_owned()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
@@ -552,7 +558,10 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
|
||||
view.goto = Some((view.current, 0.0));
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("{:.0}%", view.zoom * 100.0)).font(theme::regular(10.5)).color(t.text_faint));
|
||||
let zoom = format!("{:.0}%", view.zoom * 100.0);
|
||||
ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
|
||||
view.step_page(true);
|
||||
|
||||
@@ -251,6 +251,43 @@ pub enum Lock {
|
||||
Permissions,
|
||||
}
|
||||
|
||||
/// The combine list's field-name conflicts, computed once per list (it opens the files):
|
||||
/// for each file, the renames combining would make of its form fields. Keyed by the file ids
|
||||
/// and their lock state, so unlocking a file refreshes it.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ConflictPreview {
|
||||
key: (Vec<u64>, Vec<bool>),
|
||||
per_file: Vec<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl ConflictPreview {
|
||||
/// Bring the preview in step with `files`, opening them through the engine when the list
|
||||
/// changed. A file split into parts is combined as one file, once — each group's first
|
||||
/// entry stands for it, as the combine does — and every part shows the file's preview.
|
||||
/// A file that cannot be opened previews as nothing (its row says why).
|
||||
fn refresh(&mut self, session: &pdfcraft_engine::Session, files: &[CombineFile]) {
|
||||
let key = (files.iter().map(|f| f.id).collect(), files.iter().map(|f| f.password.is_some()).collect());
|
||||
if self.key == key && self.per_file.len() == files.len() {
|
||||
return;
|
||||
}
|
||||
let mut groups: Vec<u64> = Vec::new();
|
||||
let mut sources: Vec<pdfcraft_engine::CombineSource> = Vec::new();
|
||||
let mut passwords: Vec<Option<&str>> = Vec::new();
|
||||
for f in files {
|
||||
if groups.contains(&f.group) {
|
||||
continue;
|
||||
}
|
||||
groups.push(f.group);
|
||||
sources.push((f.name.clone(), f.bytes.clone(), None));
|
||||
passwords.push(f.password.as_ref().map(|p| p.0.as_str()));
|
||||
}
|
||||
let per_group = session.combine_field_conflicts(&sources, &passwords);
|
||||
self.per_file =
|
||||
files.iter().map(|f| groups.iter().position(|g| *g == f.group).and_then(|at| per_group.get(at)).cloned().unwrap_or_default()).collect();
|
||||
self.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock…'s password box: the rows it unlocks and what has been typed.
|
||||
#[derive(Clone, Debug)]
|
||||
struct UnlockPrompt {
|
||||
@@ -550,6 +587,10 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let files_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
|
||||
// Ranges are checked before anything below reads them.
|
||||
let checks: Vec<Result<usize, String>> = app.combine_draft.iter_mut().map(CombineFile::selection).collect();
|
||||
// Same-named form fields across the files are renamed when combining: preview it once per
|
||||
// list, so the Warnings column can say so before anything is combined.
|
||||
app.combine_conflicts.refresh(&app.session, &app.combine_draft);
|
||||
let conflicts = app.combine_conflicts.per_file.clone();
|
||||
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));
|
||||
@@ -741,7 +782,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
events.action = Some(action);
|
||||
}
|
||||
} else {
|
||||
let table_events = table(app, ui, &t, &checks, &selected);
|
||||
let table_events = table(app, ui, &t, &checks, &selected, &conflicts);
|
||||
events.range_focused |= table_events.range_focused;
|
||||
events.range_changed |= table_events.range_changed;
|
||||
if table_events.action.is_some() {
|
||||
@@ -1178,7 +1219,14 @@ fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// The file table.
|
||||
fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result<usize, String>], selected: &[bool]) -> TableEvents {
|
||||
fn table(
|
||||
app: &mut PdfCraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
checks: &[Result<usize, String>],
|
||||
selected: &[bool],
|
||||
conflicts: &[Vec<(String, String)>],
|
||||
) -> TableEvents {
|
||||
let mut events = TableEvents::default();
|
||||
// The same file twice is allowed (e.g. a cover sheet), but probably a mistake.
|
||||
// (A file's parts are the one file, not copies.)
|
||||
@@ -1196,7 +1244,7 @@ fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result<
|
||||
app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1);
|
||||
}
|
||||
let widths = app.combine_columns.resolved(available);
|
||||
let rows = Rows { checks, selected, twice: &twice, dragging, sort: app.combine_tab.sort };
|
||||
let rows = Rows { checks, selected, twice: &twice, conflicts, dragging, sort: app.combine_tab.sort };
|
||||
let mut heading_event = None;
|
||||
let mut shown = None;
|
||||
egui::ScrollArea::horizontal()
|
||||
@@ -1233,6 +1281,8 @@ struct Rows<'a> {
|
||||
checks: &'a [Result<usize, String>],
|
||||
selected: &'a [bool],
|
||||
twice: &'a [bool],
|
||||
/// What combining would rename of this file's form fields (the field-name conflict preview).
|
||||
conflicts: &'a [Vec<(String, String)>],
|
||||
dragging: bool,
|
||||
sort: Option<(SortKey, bool)>,
|
||||
}
|
||||
@@ -1423,6 +1473,19 @@ fn table_body(
|
||||
if rows.twice.get(i).copied().unwrap_or(false) {
|
||||
warnings.push(tl!("Added more than once").to_string());
|
||||
}
|
||||
if let Some(renames) = rows.conflicts.get(i).filter(|r| !r.is_empty()) {
|
||||
let shown: Vec<String> = renames.iter().take(3).map(|(old, new)| format!("{old} → {new}")).collect();
|
||||
let list = shown.join(", ");
|
||||
warnings.push(crate::i18n::fmt(
|
||||
&crate::i18n::trn(
|
||||
crate::i18n::current(),
|
||||
renames.len() as u64,
|
||||
"{n} field name collides with earlier files and will be renamed: {list}",
|
||||
"{n} field names collide with earlier files and will be renamed: {list}",
|
||||
),
|
||||
&[("list", &list)],
|
||||
));
|
||||
}
|
||||
let (icon, colour, first) = match (errors.first(), warnings.first()) {
|
||||
(Some(e), _) => ("circle-x", ERROR, e.clone()),
|
||||
(None, Some(w)) => ("triangle-alert", WARNING, w.clone()),
|
||||
|
||||
@@ -581,6 +581,7 @@ impl PdfCraftApp {
|
||||
"create.blank" => self.create_blank(),
|
||||
"create.file" => self.open_dialog(),
|
||||
"create.multiple" => self.create_multiple_dialog(),
|
||||
"create.multiple_separate" => self.create_multiple_separate_dialog(),
|
||||
"create.images" => self.create_from_images_dialog(),
|
||||
"create.clipboard" => self.create_from_clipboard(),
|
||||
"optimize.reduce" => self.reduce_file_size(),
|
||||
|
||||
@@ -1140,7 +1140,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0)));
|
||||
ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0)));
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -10,10 +10,9 @@ use pdfcraft_render::{Annotation, DocInfo};
|
||||
|
||||
use crate::canvas::DocView;
|
||||
use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge};
|
||||
use crate::icons;
|
||||
use crate::panels::Nav;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets;
|
||||
use crate::{icons, widgets};
|
||||
|
||||
/// Accent bar of the selected card (11-visual-spec §3: `#0165DD`).
|
||||
const CARD_ACCENT: Color32 = Color32::from_rgb(0x01, 0x65, 0xDD);
|
||||
@@ -208,7 +207,8 @@ pub(crate) fn show(
|
||||
if g.is_some() && g != group {
|
||||
ui.add_space(6.0);
|
||||
let title = g.clone().unwrap_or_default();
|
||||
let shown = if sort == SortBy::Author && a.author.is_some() { title.as_str() } else { tl!(&title) };
|
||||
// Catalog labels are already in display order; an author's name is put in it here.
|
||||
let shown = if sort == SortBy::Author && a.author.is_some() { crate::bidi::visual_auto(&title) } else { tl!(&title).into() };
|
||||
ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
group = g;
|
||||
}
|
||||
@@ -266,7 +266,8 @@ fn card(
|
||||
let mut buttons_left = f32::INFINITY;
|
||||
let head = ui.horizontal(|ui| {
|
||||
avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
|
||||
ui.label(egui::RichText::new(a.author.as_deref().unwrap_or_else(|| tl!("Unknown author"))).font(theme::semibold(12.5)).color(t.text));
|
||||
let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto);
|
||||
ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text));
|
||||
if let Some(m) = &a.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
}
|
||||
@@ -312,16 +313,16 @@ fn card(
|
||||
});
|
||||
} else {
|
||||
let body = a.contents.as_deref().unwrap_or("");
|
||||
let text = if body.is_empty() {
|
||||
egui::RichText::new(tl!(kind_label(a))).italics().color(t.text_faint)
|
||||
if body.is_empty() {
|
||||
let text = egui::RichText::new(tl!(kind_label(a))).italics().color(t.text_faint).font(theme::regular(13.0));
|
||||
let mut label = egui::Label::new(text).wrap();
|
||||
if !selected {
|
||||
label = label.truncate();
|
||||
}
|
||||
ui.add(label);
|
||||
} else {
|
||||
egui::RichText::new(body).color(t.text)
|
||||
};
|
||||
let mut label = egui::Label::new(text.font(theme::regular(13.0))).wrap();
|
||||
if !selected {
|
||||
label = label.truncate();
|
||||
widgets::wrapped_text(ui, body, theme::regular(13.0), t.text, !selected);
|
||||
}
|
||||
ui.add(label);
|
||||
}
|
||||
if let Some((icon, label, c)) = status.and_then(status_badge) {
|
||||
ui.add_space(4.0);
|
||||
@@ -339,15 +340,13 @@ fn card(
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(r.author.as_deref().unwrap_or_else(|| tl!("Reply"))).font(theme::semibold(12.0)));
|
||||
let author = r.author.as_deref().map_or_else(|| tl!("Reply").into(), crate::bidi::visual_auto);
|
||||
ui.label(egui::RichText::new(author).font(theme::semibold(12.0)));
|
||||
if let Some(m) = &r.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
}
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(r.contents.as_deref().unwrap_or("")).font(theme::regular(12.5)).color(t.text))
|
||||
.wrap(),
|
||||
);
|
||||
widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false);
|
||||
});
|
||||
});
|
||||
let x = resp.response.rect.left() + 12.0;
|
||||
@@ -549,7 +548,8 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Author")).small());
|
||||
for a in &authors {
|
||||
toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a });
|
||||
let name = crate::bidi::visual_auto(a);
|
||||
toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { &name });
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Status")).small());
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Create a PDF ▸ Multiple files: the picked PDFs, images and text files become one new,
|
||||
//! unsaved document, shown in the page grid: remove pages, insert more files between pages,
|
||||
//! reorder, then save what the grid shows.
|
||||
//! Create a PDF from multiple files. Combine opens one unsaved document in the page grid;
|
||||
//! Separate writes one PDF per convertible source without opening tabs.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::EditError;
|
||||
use pdfcraft_engine::{EditError, SourceKind};
|
||||
|
||||
use crate::PdfCraftApp;
|
||||
|
||||
@@ -25,6 +26,78 @@ impl From<EditError> for Stop {
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Convert each selected non-PDF file into its own PDF. Existing outputs and duplicate input
|
||||
/// names receive numbered names, so a batch never silently replaces an earlier file.
|
||||
pub(crate) fn stage_create_separate<I>(&mut self, files: I, dir: Option<PathBuf>)
|
||||
where
|
||||
I: IntoIterator<Item = Result<(String, Vec<u8>), (String, String)>>,
|
||||
{
|
||||
let mut used = HashSet::new();
|
||||
let mut made = 0usize;
|
||||
let mut refused = Vec::new();
|
||||
for file in files {
|
||||
let (name, bytes) = match file {
|
||||
Ok(file) => file,
|
||||
Err((name, error)) => {
|
||||
refused.push(format!("{name} ({error})"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (kind, pdf) = match self.session.convert_to_pdf(&name, &Arc::new(bytes)) {
|
||||
Ok(converted) => converted,
|
||||
Err(error) => {
|
||||
refused.push(format!("{name} ({error})"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if kind == SourceKind::Pdf {
|
||||
refused.push(format!("{name} ({})", tl!("already a PDF")));
|
||||
continue;
|
||||
}
|
||||
let base = crate::files::file_stem_from_user(stem(&name));
|
||||
let base = if base.is_empty() { "document" } else { &base };
|
||||
let mut output = None;
|
||||
for number in 1..10_000 {
|
||||
let filename = if number == 1 { format!("{base}.pdf") } else { format!("{base} ({number}).pdf") };
|
||||
let candidate = dir.as_ref().map(|d| d.join(&filename));
|
||||
if !used.contains(&filename.to_lowercase()) && !candidate.as_ref().is_some_and(|path| path.exists()) {
|
||||
used.insert(filename.to_lowercase());
|
||||
output = Some((filename, candidate));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((filename, target)) = output else {
|
||||
refused.push(format!("{name} ({})", tl!("no unused output name")));
|
||||
continue;
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let result = match target {
|
||||
Some(path) => crate::editing::write_atomically(&path.to_string_lossy(), &pdf).map_err(|e| e.to_string()),
|
||||
None => Err(tl!("no output folder was chosen").to_string()),
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let result = {
|
||||
let _ = target;
|
||||
crate::editing::download(&filename, &pdf)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => made += 1,
|
||||
Err(error) => refused.push(format!("{filename} ({error})")),
|
||||
}
|
||||
}
|
||||
if made > 0 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(dir) = &dir {
|
||||
self.notify_fmt("Wrote {n} files to {dir}", &[("n", &made.to_string()), ("dir", &dir.display().to_string())]);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify(crate::i18n::trn(crate::i18n::current(), made as u64, "Downloaded {n} PDF", "Downloaded {n} PDFs"));
|
||||
}
|
||||
if !refused.is_empty() {
|
||||
self.notify(crate::i18n::fmt(tl!("Left out: {names}"), &[("names", &refused.join(", "))]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the picked files, join them in the order picked, and open the result in the page
|
||||
/// grid. A file that can't be converted is left out (and named in a notice); a file that can't be
|
||||
/// read stops the run. Files are read as their turn comes, so one file's bytes are held at a time.
|
||||
|
||||
@@ -845,7 +845,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
Dialog::Signature => {
|
||||
let (apply, cancel, browse) = crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview);
|
||||
let (apply, cancel, browse) =
|
||||
crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo);
|
||||
if browse {
|
||||
app.pick_signature_image();
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ impl PdfCraftApp {
|
||||
self.notify_tr("A document needs at least one page: copy instead");
|
||||
return;
|
||||
}
|
||||
self.page_clipboard = Some(crate::PageClip { name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
|
||||
self.page_clipboard = Some(crate::PageClip { from: id, name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
|
||||
if cut {
|
||||
self.apply_edit(Edit::DeletePages { pages: pages.clone() });
|
||||
}
|
||||
@@ -365,7 +365,17 @@ impl PdfCraftApp {
|
||||
let Some(i) = self.active else { return };
|
||||
let at = self.views[i].target_pages().into_iter().max().map_or(0, |p| p + 1);
|
||||
let count = clip.pages.len();
|
||||
if self.apply_edit(Edit::InsertPagesFrom { name: clip.name.clone(), bytes: clip.bytes.clone(), pages: Some(clip.pages.clone()), at }) {
|
||||
// Pasting into the document the pages came from is a duplicate (or, after Cut, a move):
|
||||
// its fields keep their names. Pasting into another document renames colliding ones.
|
||||
let field_conflicts =
|
||||
if self.views[i].id == clip.from { pdfcraft_engine::FieldConflicts::Merge } else { pdfcraft_engine::FieldConflicts::Rename };
|
||||
if self.apply_edit(Edit::InsertPagesFrom {
|
||||
name: clip.name.clone(),
|
||||
bytes: clip.bytes.clone(),
|
||||
pages: Some(clip.pages.clone()),
|
||||
at,
|
||||
field_conflicts,
|
||||
}) {
|
||||
self.views[i].select_pages(&(at..at + count).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ pub enum FilePurpose {
|
||||
Ocr,
|
||||
/// Create a PDF ▸ Multiple files (PDFs, images and text).
|
||||
CreateMultiple,
|
||||
/// Create one PDF per selected non-PDF file.
|
||||
CreateMultipleSeparate,
|
||||
/// Find text and redact ▸ Import list: a text file with one word or phrase per line.
|
||||
RedactWords,
|
||||
}
|
||||
@@ -64,7 +66,7 @@ fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog {
|
||||
if purpose == FilePurpose::RedactWords {
|
||||
return dialog.add_filter(tl!("Text"), &["txt"]);
|
||||
}
|
||||
if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::InsertPages | FilePurpose::Combine) {
|
||||
if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::CreateMultipleSeparate | FilePurpose::InsertPages | FilePurpose::Combine) {
|
||||
return dialog.add_filter("PDF", &["pdf"]);
|
||||
}
|
||||
let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect();
|
||||
@@ -228,6 +230,11 @@ impl PdfCraftApp {
|
||||
self.pick_files(FilePurpose::CreateMultiple, true);
|
||||
}
|
||||
|
||||
/// Create a PDF ▸ Separate PDFs: pick source files, then a destination folder.
|
||||
pub fn create_multiple_separate_dialog(&mut self) {
|
||||
self.pick_files(FilePurpose::CreateMultipleSeparate, true);
|
||||
}
|
||||
|
||||
/// Scan & OCR ▸ Recognize text ▸ In multiple files: ask for the PDFs.
|
||||
pub fn ocr_files_dialog(&mut self) {
|
||||
self.pick_files(FilePurpose::Ocr, true);
|
||||
@@ -291,6 +298,25 @@ impl PdfCraftApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if purpose == FilePurpose::CreateMultipleSeparate {
|
||||
if !paths.is_empty() {
|
||||
let paths = paths.to_vec();
|
||||
let convert = move |app: &mut Self, dir: std::path::PathBuf| {
|
||||
app.stage_create_separate(paths.iter().map(|p| read_named(p)), Some(dir));
|
||||
};
|
||||
match self.export_dir_override.clone() {
|
||||
Some(dir) => convert(self, std::path::PathBuf::from(dir)),
|
||||
None => {
|
||||
self.ask_one(
|
||||
crate::pickers::Ask::Folder(rfd::AsyncFileDialog::new().set_title(tl!("Choose a folder for the exported files"))),
|
||||
None,
|
||||
convert,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut modified = Vec::new();
|
||||
for p in paths {
|
||||
@@ -433,6 +459,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
FilePurpose::Ocr => self.ocr_files(files),
|
||||
FilePurpose::CreateMultiple => self.stage_create_multiple(files.into_iter().map(Ok)),
|
||||
FilePurpose::CreateMultipleSeparate => {
|
||||
let dir = self.export_dir_override.as_ref().map(std::path::PathBuf::from);
|
||||
self.stage_create_separate(files.into_iter().map(Ok), dir);
|
||||
}
|
||||
FilePurpose::RedactWords => {
|
||||
let Some((name, bytes)) = files.into_iter().next() else { return };
|
||||
if bytes.len() > crate::redact_ui::MAX_WORD_LIST_BYTES {
|
||||
@@ -506,7 +536,13 @@ impl PdfCraftApp {
|
||||
self.session.convert_to_pdf(&name, &Arc::new(bytes)).and_then(|(_, pdf)| Ok((self.session.page_count_of(&name, &pdf)?, pdf)));
|
||||
match converted {
|
||||
Ok((pages, bytes)) => {
|
||||
if self.apply_edit(Edit::InsertPagesFrom { name, bytes, pages: None, at }) {
|
||||
if self.apply_edit(Edit::InsertPagesFrom {
|
||||
name,
|
||||
bytes,
|
||||
pages: None,
|
||||
at,
|
||||
field_conflicts: pdfcraft_engine::FieldConflicts::Rename,
|
||||
}) {
|
||||
at = at.saturating_add(pages);
|
||||
}
|
||||
}
|
||||
|
||||
+481
-18
@@ -4,7 +4,8 @@
|
||||
//! can be moved, deleted and undone like any comment.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style};
|
||||
use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError};
|
||||
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn};
|
||||
use pdfcraft_render::{DocInfo, PageInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
@@ -76,7 +77,11 @@ pub struct SigDraft {
|
||||
pub drawing: bool,
|
||||
/// The Image tab. The file is held in the draft until Apply.
|
||||
pub image_mode: bool,
|
||||
/// The Image or Scan tab's result.
|
||||
pub image: Option<SignatureImage>,
|
||||
/// The Scan tab: a photo of a signature on paper, cleaned into `image`.
|
||||
pub scan_mode: bool,
|
||||
pub scan: Option<ScanDraft>,
|
||||
/// Creating initials (otherwise the signature).
|
||||
pub initials: bool,
|
||||
/// Replacing an existing saved value.
|
||||
@@ -99,7 +104,10 @@ impl SigDraft {
|
||||
}
|
||||
|
||||
fn ready(&self) -> bool {
|
||||
if self.image_mode {
|
||||
if self.scan_mode {
|
||||
// A preview made while the frame or slider is still moving is not the result.
|
||||
self.scan.as_ref().is_some_and(|s| s.drag.is_none() && s.full && !s.scanning()) && self.image.is_some()
|
||||
} else if self.image_mode {
|
||||
self.image.is_some()
|
||||
} else if self.drawing {
|
||||
self.strokes.iter().any(|s| s.len() > 1)
|
||||
@@ -110,7 +118,9 @@ impl SigDraft {
|
||||
}
|
||||
|
||||
pub fn saved(&self) -> Option<SavedSig> {
|
||||
if self.image_mode {
|
||||
if self.scan_mode {
|
||||
self.ready().then(|| self.image.clone().map(SavedSig::Image)).flatten()
|
||||
} else if self.image_mode {
|
||||
self.image.clone().map(SavedSig::Image)
|
||||
} else if self.drawing {
|
||||
Some(SavedSig::Drawn(self.strokes.clone()))
|
||||
@@ -120,6 +130,203 @@ impl SigDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ScanDraft {
|
||||
pub photo: SignaturePhoto,
|
||||
pub frame: PhotoRect,
|
||||
pub sensitivity: u8,
|
||||
/// Changes whenever the photo's pixels do (a new photo or a turn), to refresh its texture.
|
||||
version: u64,
|
||||
drag: Option<FrameDrag>,
|
||||
/// The draft's image is the full-resolution result, not a quick preview.
|
||||
full: bool,
|
||||
/// Why the frame gives no signature, shown in place of the preview.
|
||||
pub problem: Option<String>,
|
||||
/// Scans run here while the frame or slider moves, so a frame never waits for one.
|
||||
worker: ScanWorker,
|
||||
}
|
||||
|
||||
/// What one scan cleans: the frame and sensitivity at the time it was asked for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ScanRequest {
|
||||
frame: PhotoRect,
|
||||
sensitivity: u8,
|
||||
full: bool,
|
||||
}
|
||||
|
||||
type ScanAnswer = Result<Result<SignatureImage, ScanError>, String>;
|
||||
|
||||
/// The scan running on a worker thread (desktop) and the newest request made meanwhile, which
|
||||
/// starts when it answers; requests in between are skipped. It is not part of the draft's value:
|
||||
/// a clone has nothing running and comparisons ignore it.
|
||||
#[derive(Default)]
|
||||
struct ScanWorker {
|
||||
running: Option<(ScanRequest, std::sync::mpsc::Receiver<ScanAnswer>)>,
|
||||
queued: Option<ScanRequest>,
|
||||
}
|
||||
|
||||
impl Clone for ScanWorker {
|
||||
fn clone(&self) -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ScanWorker {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ScanWorker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ScanWorker").field("running", &self.running.as_ref().map(|(r, _)| r)).field("queued", &self.queued).finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag on the photo, in photo pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum FrameDrag {
|
||||
/// Moving the frame, grabbed at this offset from its top-left corner.
|
||||
Move([f32; 2]),
|
||||
/// Drawing a new frame or resizing from a corner, with the opposite corner fixed here.
|
||||
Span([f32; 2]),
|
||||
}
|
||||
|
||||
fn next_photo_version() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
impl ScanDraft {
|
||||
/// Frame the signature (turning a sideways one upright) with the default sensitivity.
|
||||
pub fn new(mut photo: SignaturePhoto) -> Self {
|
||||
let frame = photo.auto_frame();
|
||||
Self {
|
||||
photo,
|
||||
frame,
|
||||
sensitivity: DEFAULT_SENSITIVITY,
|
||||
version: next_photo_version(),
|
||||
drag: None,
|
||||
full: false,
|
||||
problem: None,
|
||||
worker: ScanWorker::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&self, full: bool) -> ScanRequest {
|
||||
ScanRequest { frame: self.frame, sensitivity: self.sensitivity, full }
|
||||
}
|
||||
|
||||
fn run(photo: &SignaturePhoto, req: ScanRequest) -> ScanAnswer {
|
||||
let max_side = if req.full { SCAN_MAX_SIDE } else { PREVIEW_MAX_SIDE };
|
||||
pdfcraft_engine::guard(|| photo.scan(req.frame, req.sensitivity, max_side))
|
||||
}
|
||||
|
||||
/// Clean the framed area into `image` now: quickly (`full` false) while something is being
|
||||
/// dragged, at full resolution otherwise. A scan still running in the background is dropped.
|
||||
pub fn rescan(&mut self, image: &mut Option<SignatureImage>, full: bool) {
|
||||
self.worker = ScanWorker::default();
|
||||
let req = self.request(full);
|
||||
let answer = Self::run(&self.photo, req);
|
||||
self.apply(image, req, answer);
|
||||
}
|
||||
|
||||
/// Like [`Self::rescan`], but on a worker thread on desktop, so the frame that asked doesn't
|
||||
/// wait (a scan of a large frame takes a noticeable fraction of a second). The draft isn't
|
||||
/// ready until the answer for the current frame and sensitivity arrives; [`Self::poll`]
|
||||
/// picks it up. Where threads are unavailable (the web) it scans at once.
|
||||
pub fn rescan_in_background(&mut self, image: &mut Option<SignatureImage>, full: bool, ctx: Option<&egui::Context>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return self.rescan(image, full);
|
||||
}
|
||||
let req = self.request(full);
|
||||
self.full = false;
|
||||
if self.worker.running.is_some() {
|
||||
self.worker.queued = Some(req);
|
||||
} else if !self.start(req, ctx) {
|
||||
self.rescan(image, full);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start `req` on a worker thread; false if no thread could be started.
|
||||
fn start(&mut self, req: ScanRequest, ctx: Option<&egui::Context>) -> bool {
|
||||
let photo = self.photo.clone();
|
||||
let ctx = ctx.cloned();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("signature-scan".into()).spawn(move || {
|
||||
// The receiver may be gone (the dialog closed or a newer photo): nothing to report.
|
||||
let _ = tx.send(Self::run(&photo, req));
|
||||
if let Some(ctx) = ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
return false;
|
||||
}
|
||||
self.worker.running = Some((req, rx));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether a background scan is running or waiting to run.
|
||||
pub fn scanning(&self) -> bool {
|
||||
self.worker.running.is_some() || self.worker.queued.is_some()
|
||||
}
|
||||
|
||||
/// Pick up a finished background scan (each frame), and start the newest waiting request.
|
||||
pub fn poll(&mut self, image: &mut Option<SignatureImage>, ctx: Option<&egui::Context>) {
|
||||
let Some((req, rx)) = &self.worker.running else { return };
|
||||
let answer = match rx.try_recv() {
|
||||
Ok(answer) => answer,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the scan stopped unexpectedly".into()),
|
||||
};
|
||||
let req = *req;
|
||||
self.worker.running = None;
|
||||
match self.worker.queued.take() {
|
||||
Some(next) => {
|
||||
// Shown as a preview while the newer request runs.
|
||||
self.apply(image, ScanRequest { full: false, ..req }, answer);
|
||||
if !self.start(next, ctx) {
|
||||
self.rescan(image, next.full);
|
||||
}
|
||||
}
|
||||
None => self.apply(image, req, answer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a scan's answer. It is the result only if it was full and for the current frame and
|
||||
/// sensitivity.
|
||||
fn apply(&mut self, image: &mut Option<SignatureImage>, req: ScanRequest, result: ScanAnswer) {
|
||||
self.full = req.full && req.frame == self.frame && req.sensitivity == self.sensitivity;
|
||||
match result {
|
||||
Ok(Ok(scanned)) => {
|
||||
*image = Some(scanned);
|
||||
self.problem = None;
|
||||
}
|
||||
Ok(Err(ScanError::NoInk)) => {
|
||||
*image = None;
|
||||
self.problem =
|
||||
Some(tl!("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.").to_string());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
*image = None;
|
||||
self.problem = Some(e.to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
*image = None;
|
||||
self.problem = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn turn(&mut self, turn: Turn, image: &mut Option<SignatureImage>, ctx: Option<&egui::Context>) {
|
||||
self.frame = self.photo.turn(turn, self.frame);
|
||||
self.version = next_photo_version();
|
||||
self.rescan_in_background(image, true, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl FillTool {
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
@@ -509,30 +716,48 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
None
|
||||
}
|
||||
|
||||
/// The signature pad: type, draw or import an image. Returns Apply, Cancel and Browse.
|
||||
/// The signature pad: type, draw, import an image or scan a photo. Returns Apply, Cancel and
|
||||
/// Browse (for an image or, on the Scan tab, a photo).
|
||||
pub(crate) fn signature_pad(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
d: &mut SigDraft,
|
||||
preview: &mut Option<(SavedSig, egui::TextureHandle)>,
|
||||
photo_texture: &mut Option<(u64, egui::TextureHandle)>,
|
||||
) -> (bool, bool, bool) {
|
||||
let what = if d.initials { tl!("initials") } else { tl!("signature") };
|
||||
let title = if d.editing { tl!("Change {what}") } else { tl!("Create {what}") };
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(title, &[("what", what)])).font(crate::theme::semibold(18.0)));
|
||||
ui.horizontal(|ui| {
|
||||
if crate::widgets::pill_button(ui, tl_ctx!("signature pad", "Type"), !d.drawing && !d.image_mode).clicked() {
|
||||
if crate::widgets::pill_button(ui, tl_ctx!("signature pad", "Type"), !d.drawing && !d.image_mode && !d.scan_mode).clicked() {
|
||||
d.drawing = false;
|
||||
d.image_mode = false;
|
||||
d.scan_mode = false;
|
||||
}
|
||||
if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing && !d.image_mode).clicked() {
|
||||
if crate::widgets::pill_button(ui, tl!("Draw"), d.drawing && !d.image_mode && !d.scan_mode).clicked() {
|
||||
d.drawing = true;
|
||||
d.image_mode = false;
|
||||
d.scan_mode = false;
|
||||
}
|
||||
if crate::widgets::pill_button(ui, tl!("Image"), d.image_mode).clicked() {
|
||||
d.image_mode = true;
|
||||
d.scan_mode = false;
|
||||
}
|
||||
if crate::widgets::pill_button(ui, tl!("Scan"), d.scan_mode).clicked() && !d.scan_mode {
|
||||
d.scan_mode = true;
|
||||
d.image_mode = false;
|
||||
// The Image tab shares the result; show this tab's own again.
|
||||
if let Some(scan) = d.scan.as_mut() {
|
||||
scan.rescan_in_background(&mut d.image, true, Some(ui.ctx()));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if d.scan_mode {
|
||||
let browse = scan_tab(ui, t, d, preview, photo_texture);
|
||||
let (apply, cancel) = pad_buttons(ui, d);
|
||||
return (apply, cancel, browse);
|
||||
}
|
||||
if d.image_mode {
|
||||
ui.label(egui::RichText::new(tl!("Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved.")).color(t.text_muted));
|
||||
let browse = crate::widgets::ghost_button(ui, "folder", tl!("Browse…")).clicked();
|
||||
@@ -597,6 +822,173 @@ pub(crate) fn signature_pad(
|
||||
(apply, cancel, false)
|
||||
}
|
||||
|
||||
/// The Scan tab above the pad buttons. Returns whether a photo should be chosen.
|
||||
fn scan_tab(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
d: &mut SigDraft,
|
||||
preview: &mut Option<(SavedSig, egui::TextureHandle)>,
|
||||
photo_texture: &mut Option<(u64, egui::TextureHandle)>,
|
||||
) -> bool {
|
||||
ui.label(
|
||||
egui::RichText::new(tl!(
|
||||
"Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper."
|
||||
))
|
||||
.color(t.text_muted),
|
||||
);
|
||||
let mut browse = false;
|
||||
ui.horizontal(|ui| {
|
||||
browse = crate::widgets::ghost_button(ui, "camera", tl!("Choose photo…")).clicked();
|
||||
if let Some(scan) = d.scan.as_mut() {
|
||||
ui.add_space(8.0);
|
||||
if crate::icons::button(ui, "rotate-ccw", 28.0, false, tl!("Rotate counterclockwise")).clicked() {
|
||||
scan.turn(Turn::CounterClockwise, &mut d.image, Some(ui.ctx()));
|
||||
}
|
||||
if crate::icons::button(ui, "rotate-cw", 28.0, false, tl!("Rotate clockwise")).clicked() {
|
||||
scan.turn(Turn::Clockwise, &mut d.image, Some(ui.ctx()));
|
||||
}
|
||||
}
|
||||
});
|
||||
let (area, resp) = ui.allocate_exact_size(vec2(460.0, 230.0), Sense::drag());
|
||||
let painter = ui.painter_at(area);
|
||||
painter.rect_filled(area, CornerRadius::same(6), t.pasteboard);
|
||||
painter.rect_stroke(area, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
if let Some(scan) = d.scan.as_mut() {
|
||||
scan.poll(&mut d.image, Some(ui.ctx()));
|
||||
}
|
||||
let Some(scan) = d.scan.as_mut() else {
|
||||
painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo")));
|
||||
return browse;
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo")));
|
||||
// The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points.
|
||||
let [pw, ph] = scan.photo.size();
|
||||
let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32);
|
||||
let fit = area.shrink(8.0);
|
||||
let scale = (fit.width() / pw).min(fit.height() / ph);
|
||||
let shown = egui::Rect::from_center_size(fit.center(), vec2(pw * scale, ph * scale));
|
||||
if photo_texture.as_ref().is_none_or(|(v, _)| *v != scan.version) {
|
||||
let thumb = scan.photo.thumbnail(1024);
|
||||
let size = [thumb.width() as usize, thumb.height() as usize];
|
||||
let pixels = egui::ColorImage::from_rgba_unmultiplied(size, thumb.as_raw());
|
||||
*photo_texture = Some((scan.version, ui.ctx().load_texture("signature-photo", pixels, egui::TextureOptions::LINEAR)));
|
||||
}
|
||||
if let Some((_, tex)) = photo_texture {
|
||||
painter.image(tex.id(), shown, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let to_screen = |x: f32, y: f32| pos2(shown.left() + x * scale, shown.top() + y * scale);
|
||||
let to_photo = |p: Pos2| [((p.x - shown.left()) / scale).clamp(0.0, pw), ((p.y - shown.top()) / scale).clamp(0.0, ph)];
|
||||
let frame_screen = |f: PhotoRect| {
|
||||
egui::Rect::from_min_max(
|
||||
to_screen(f.x as f32, f.y as f32),
|
||||
to_screen(f.x.saturating_add(f.width) as f32, f.y.saturating_add(f.height) as f32),
|
||||
)
|
||||
};
|
||||
|
||||
// Move the frame from inside, resize it from a corner, or draw a new one elsewhere.
|
||||
let handle = 10.0;
|
||||
if resp.drag_started()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let fr = frame_screen(scan.frame);
|
||||
let corners = [fr.left_top(), fr.right_top(), fr.left_bottom(), fr.right_bottom()];
|
||||
let opposite = [fr.right_bottom(), fr.left_bottom(), fr.right_top(), fr.left_top()];
|
||||
let at = to_photo(p);
|
||||
scan.drag = Some(if let Some(i) = corners.iter().position(|c| c.distance(p) <= handle) {
|
||||
FrameDrag::Span(opposite.get(i).map_or(at, |o| to_photo(*o)))
|
||||
} else if fr.contains(p) {
|
||||
FrameDrag::Move([at[0] - scan.frame.x as f32, at[1] - scan.frame.y as f32])
|
||||
} else {
|
||||
FrameDrag::Span(at)
|
||||
});
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(drag), Some(p)) = (scan.drag, resp.interact_pointer_pos())
|
||||
{
|
||||
let at = to_photo(p);
|
||||
let (x0, y0, x1, y1) = match drag {
|
||||
FrameDrag::Move(grab) => {
|
||||
let (w, h) = (scan.frame.width as f32, scan.frame.height as f32);
|
||||
let x = (at[0] - grab[0]).clamp(0.0, (pw - w).max(0.0));
|
||||
let y = (at[1] - grab[1]).clamp(0.0, (ph - h).max(0.0));
|
||||
(x, y, x + w, y + h)
|
||||
}
|
||||
FrameDrag::Span(anchor) => (anchor[0].min(at[0]), anchor[1].min(at[1]), anchor[0].max(at[0]), anchor[1].max(at[1])),
|
||||
};
|
||||
// At least a few screen points, so a frame can always be grabbed again.
|
||||
let min = (12.0 / scale).max(1.0);
|
||||
let frame = PhotoRect {
|
||||
x: x0.round() as u32,
|
||||
y: y0.round() as u32,
|
||||
width: (x1 - x0).max(min).round() as u32,
|
||||
height: (y1 - y0).max(min).round() as u32,
|
||||
}
|
||||
.clamped(pw as u32, ph as u32);
|
||||
if frame != scan.frame {
|
||||
scan.frame = frame;
|
||||
scan.rescan_in_background(&mut d.image, false, Some(ui.ctx()));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() && scan.drag.take().is_some() {
|
||||
scan.rescan_in_background(&mut d.image, true, Some(ui.ctx()));
|
||||
}
|
||||
|
||||
// Dim the photo outside the frame, then outline it with corner handles.
|
||||
let fr = frame_screen(scan.frame).intersect(shown);
|
||||
let dim = Color32::from_black_alpha(120);
|
||||
for r in [
|
||||
egui::Rect::from_min_max(shown.left_top(), pos2(shown.right(), fr.top())),
|
||||
egui::Rect::from_min_max(pos2(shown.left(), fr.bottom()), shown.right_bottom()),
|
||||
egui::Rect::from_min_max(pos2(shown.left(), fr.top()), pos2(fr.left(), fr.bottom())),
|
||||
egui::Rect::from_min_max(pos2(fr.right(), fr.top()), pos2(shown.right(), fr.bottom())),
|
||||
] {
|
||||
if r.is_positive() {
|
||||
painter.rect_filled(r, CornerRadius::ZERO, dim);
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(fr, CornerRadius::ZERO, Stroke::new(2.0, t.accent), egui::StrokeKind::Middle);
|
||||
for c in [fr.left_top(), fr.right_top(), fr.left_bottom(), fr.right_bottom()] {
|
||||
let r = egui::Rect::from_center_size(c, vec2(9.0, 9.0));
|
||||
painter.rect_filled(r, CornerRadius::same(2), Color32::WHITE);
|
||||
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
if resp.hovered() && scan.drag.is_none() {
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
let fr = frame_screen(scan.frame);
|
||||
let icon = match hover {
|
||||
Some(p) if [fr.left_top(), fr.right_bottom()].iter().any(|c| c.distance(p) <= handle) => egui::CursorIcon::ResizeNwSe,
|
||||
Some(p) if [fr.right_top(), fr.left_bottom()].iter().any(|c| c.distance(p) <= handle) => egui::CursorIcon::ResizeNeSw,
|
||||
Some(p) if fr.contains(p) => egui::CursorIcon::Grab,
|
||||
_ => egui::CursorIcon::Crosshair,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
}
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let l = ui.label(tl!("Ink sensitivity"));
|
||||
let slider = ui.add(egui::Slider::new(&mut scan.sensitivity, 0..=100)).labelled_by(l.id);
|
||||
if slider.changed() {
|
||||
// Quick previews while dragging; keyboard steps get the full result at once.
|
||||
scan.rescan_in_background(&mut d.image, !slider.dragged(), Some(ui.ctx()));
|
||||
}
|
||||
if slider.drag_stopped() {
|
||||
scan.rescan_in_background(&mut d.image, true, Some(ui.ctx()));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(460.0, 110.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), Color32::WHITE);
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
if let Some(image) = &d.image {
|
||||
image_preview(ui, rect.shrink(8.0), image, preview);
|
||||
} else if let Some(problem) = &scan.problem {
|
||||
ui.put(rect.shrink(12.0), egui::Label::new(egui::RichText::new(problem.as_str()).color(Color32::from_rgb(0x8A, 0x5A, 0x00))).wrap());
|
||||
}
|
||||
browse
|
||||
}
|
||||
|
||||
fn image_texture(ui: &egui::Ui, image: &SignatureImage, cache: &mut Option<(SavedSig, egui::TextureHandle)>) -> egui::TextureId {
|
||||
let key = SavedSig::Image(image.clone());
|
||||
if cache.as_ref().is_some_and(|(s, _)| *s != key) {
|
||||
@@ -630,6 +1022,9 @@ fn pad_buttons(ui: &mut egui::Ui, d: &mut SigDraft) -> (bool, bool) {
|
||||
d.strokes.clear();
|
||||
d.text.clear();
|
||||
d.image = None;
|
||||
if d.scan_mode {
|
||||
d.scan = None;
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ready = d.ready();
|
||||
@@ -655,18 +1050,31 @@ pub(crate) struct ImageInbox {
|
||||
struct PickedImage {
|
||||
epoch: u64,
|
||||
initials: bool,
|
||||
/// A photo for the Scan tab (otherwise an image for the Image tab).
|
||||
scan: bool,
|
||||
bytes: Option<Result<Vec<u8>, String>>,
|
||||
}
|
||||
|
||||
impl crate::PdfCraftApp {
|
||||
/// Browse asynchronously, answering only into the dialog that asked for the image.
|
||||
/// Browse asynchronously, answering only into the dialog (and tab) that asked for the image
|
||||
/// or, on the Scan tab, the photo.
|
||||
pub(crate) fn pick_signature_image(&mut self) {
|
||||
let epoch = self.dialog_epoch();
|
||||
let initials = self.signature_draft.initials;
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]);
|
||||
let scan = self.signature_draft.scan_mode;
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
|
||||
if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Signature) || app.signature_draft.initials != initials {
|
||||
if app.dialog_epoch() != epoch
|
||||
|| app.dialog != Some(crate::Dialog::Signature)
|
||||
|| app.signature_draft.initials != initials
|
||||
|| app.signature_draft.scan_mode != scan
|
||||
{
|
||||
return;
|
||||
}
|
||||
if scan {
|
||||
let result = std::fs::File::open(path).map_err(ScanError::from).and_then(SignaturePhoto::read);
|
||||
app.use_signature_photo(result.map_err(|e| e.to_string()));
|
||||
return;
|
||||
}
|
||||
let result =
|
||||
@@ -682,21 +1090,22 @@ impl crate::PdfCraftApp {
|
||||
}
|
||||
let inbox = self.signature_images.clone();
|
||||
let ctx = self.ctx.clone();
|
||||
let (limit, too_large) = if scan {
|
||||
(pdfcraft_engine::signature_scan::MAX_PHOTO_BYTES, ScanError::Size.to_string())
|
||||
} else {
|
||||
(pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES, pdfcraft_engine::signature_image::SignatureImageError::Size.to_string())
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let bytes = if let Some(file) = dialog.pick_file().await {
|
||||
if file.inner().size() > pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES as f64 {
|
||||
Some(Err(pdfcraft_engine::signature_image::SignatureImageError::Size.to_string()))
|
||||
if file.inner().size() > limit as f64 {
|
||||
Some(Err(too_large))
|
||||
} else {
|
||||
// `pick_file` returns a readable handle. Read its Blob directly so a
|
||||
// rejected browser read is an error (rfd's read helper unwraps it).
|
||||
Some(match wasm_bindgen_futures::JsFuture::from(file.inner().array_buffer()).await {
|
||||
Ok(buffer) => {
|
||||
let bytes = js_sys::Uint8Array::new(&buffer);
|
||||
if bytes.length() as usize > pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES {
|
||||
Err(pdfcraft_engine::signature_image::SignatureImageError::Size.to_string())
|
||||
} else {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
if bytes.length() as usize > limit { Err(too_large) } else { Ok(bytes.to_vec()) }
|
||||
}
|
||||
Err(error) => Err(format!("{error:?}")),
|
||||
})
|
||||
@@ -704,7 +1113,7 @@ impl crate::PdfCraftApp {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
*inbox.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PickedImage { epoch, initials, bytes });
|
||||
*inbox.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PickedImage { epoch, initials, scan, bytes });
|
||||
if let Some(ctx) = ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -717,10 +1126,21 @@ impl crate::PdfCraftApp {
|
||||
let picked = self.signature_images.picked.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take();
|
||||
let Some(picked) = picked else { return };
|
||||
self.signature_images.busy.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
if self.dialog_epoch() != picked.epoch || self.dialog != Some(crate::Dialog::Signature) || self.signature_draft.initials != picked.initials {
|
||||
if self.dialog_epoch() != picked.epoch
|
||||
|| self.dialog != Some(crate::Dialog::Signature)
|
||||
|| self.signature_draft.initials != picked.initials
|
||||
|| self.signature_draft.scan_mode != picked.scan
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(bytes) = picked.bytes else { return };
|
||||
if picked.scan {
|
||||
let result = bytes.and_then(|bytes| {
|
||||
pdfcraft_engine::guard(|| SignaturePhoto::from_bytes(&bytes)).map_err(|e| e.to_string())?.map_err(|e| e.to_string())
|
||||
});
|
||||
self.use_signature_photo(result);
|
||||
return;
|
||||
}
|
||||
let result = bytes
|
||||
.and_then(|bytes| pdfcraft_engine::guard(|| SignatureImage::from_bytes(&bytes)).map_err(|e| e.to_string())?.map_err(|e| e.to_string()));
|
||||
self.use_signature_image(result);
|
||||
@@ -736,6 +1156,49 @@ impl crate::PdfCraftApp {
|
||||
Err(e) => self.notify_fmt("Couldn't import the signature image: {e}", &[("e", &e)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Files dropped while the signature pad shows the Image or Scan tab are imported there (the
|
||||
/// first one) instead of opening behind the dialog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn drop_on_signature_pad(&mut self, dropped: Vec<egui::DroppedFileHandle>) -> Vec<egui::DroppedFileHandle> {
|
||||
let d = &self.signature_draft;
|
||||
if dropped.is_empty() || self.dialog != Some(crate::Dialog::Signature) || !(d.scan_mode || d.image_mode) {
|
||||
return dropped;
|
||||
}
|
||||
let scan = d.scan_mode;
|
||||
let Some(file) = dropped.into_iter().next() else { return Vec::new() };
|
||||
let limit = pdfcraft_engine::signature_image::MAX_SIGNATURE_IMAGE_BYTES;
|
||||
let path = file.path().to_path_buf();
|
||||
let bytes: Result<Vec<u8>, String> = if path.is_absolute() {
|
||||
use std::io::Read as _;
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(&path).and_then(|f| f.take(limit as u64 + 1).read_to_end(&mut bytes)).map(|_| bytes).map_err(|e| e.to_string())
|
||||
} else {
|
||||
file.bytes().map_err(|e| e.to_string())
|
||||
};
|
||||
if scan {
|
||||
let photo =
|
||||
bytes.and_then(|b| pdfcraft_engine::guard(|| SignaturePhoto::from_bytes(&b)).map_err(|e| e.to_string())?.map_err(|e| e.to_string()));
|
||||
self.use_signature_photo(photo);
|
||||
} else {
|
||||
let image =
|
||||
bytes.and_then(|b| pdfcraft_engine::guard(|| SignatureImage::from_bytes(&b)).map_err(|e| e.to_string())?.map_err(|e| e.to_string()));
|
||||
self.use_signature_image(image);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Frame the signature in a new photo and clean it up. A previous photo stays on failure.
|
||||
fn use_signature_photo(&mut self, result: Result<SignaturePhoto, String>) {
|
||||
match result.and_then(|photo| pdfcraft_engine::guard(|| ScanDraft::new(photo))) {
|
||||
Ok(mut scan) => {
|
||||
scan.rescan_in_background(&mut self.signature_draft.image, true, self.ctx.as_ref());
|
||||
self.signature_draft.scan = Some(scan);
|
||||
self.toast = None;
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't use the photo: {e}", &[("e", &e)]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+41
-19
@@ -9,6 +9,15 @@ use crate::{PdfCraftApp, icons, panels::human_size, widgets};
|
||||
|
||||
const RECOMMENDED: [&str; 5] = ["organize", "comment", "form", "edit", "protect"];
|
||||
|
||||
/// A soft shadow that rises on hover and settles when the card is pressed.
|
||||
fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) {
|
||||
let lift = press.hover * (1.0 - press.down);
|
||||
if lift > 0.04 {
|
||||
let shadow = egui::Shadow { offset: [0, 3], blur: (14.0 * lift) as u8, spread: 0, color: t.page_shadow.gamma_multiply(0.85 * lift) };
|
||||
ui.painter().add(shadow.as_shape(rect, CornerRadius::same(10)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
@@ -56,51 +65,57 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
|
||||
let fill = if resp.hovered() { t.hover } else { t.card };
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
card_lift(ui, &t, rect, press);
|
||||
let fill = press.fill(t.card, t.hover, t.pressed);
|
||||
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
let body = rect.translate(press.offset());
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
|
||||
title_job.wrap =
|
||||
egui::text::TextWrapping { max_width: rect.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let title = ui.fonts_mut(|f| f.layout_job(title_job));
|
||||
let title_elided = title.elided;
|
||||
ui.painter().galley(rect.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
|
||||
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
|
||||
let blurb = g
|
||||
.sections
|
||||
.first()
|
||||
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
|
||||
.unwrap_or_default();
|
||||
// Fixed-height cards must leave room for their action in every language.
|
||||
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, rect.width() - 28.0);
|
||||
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
|
||||
job.wrap.max_rows = 2;
|
||||
let galley = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let blurb_elided = galley.elided;
|
||||
ui.painter().galley(rect.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(
|
||||
rect.left_bottom() + vec2(14.0, -14.0),
|
||||
body.left_bottom() + vec2(14.0, -14.0),
|
||||
Align2::LEFT_CENTER,
|
||||
tl!("Use now"),
|
||||
theme::medium(12.0),
|
||||
t.accent_text,
|
||||
);
|
||||
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
|
||||
if resp.clicked() {
|
||||
if theme::hand(resp).clicked() {
|
||||
app.select_tool(g.id);
|
||||
}
|
||||
}
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file")));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
card_lift(ui, &t, rect, press);
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(10),
|
||||
if resp.hovered() { t.hover } else { t.pasteboard },
|
||||
press.fill(t.pasteboard, t.hover, t.pressed),
|
||||
Stroke::new(1.0, t.divider),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
icons::paint(ui, Rect::from_center_size(rect.center() - vec2(0.0, 16.0), vec2(28.0, 28.0)), "folder-open", 26.0, t.icon);
|
||||
ui.painter().text(rect.center() + vec2(0.0, 22.0), Align2::CENTER_CENTER, tl!("Open file"), theme::semibold(13.0), t.text);
|
||||
if resp.clicked() {
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_center_size(body.center() - vec2(0.0, 16.0), vec2(28.0, 28.0)), "folder-open", 26.0, t.icon);
|
||||
ui.painter().text(body.center() + vec2(0.0, 22.0), Align2::CENTER_CENTER, tl!("Open file"), theme::semibold(13.0), t.text);
|
||||
if theme::hand(resp).clicked() {
|
||||
app.open_dialog();
|
||||
}
|
||||
});
|
||||
@@ -133,10 +148,16 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
for r in &app.recent {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
// The row, not its response: the pointer stays "in" the row over its remove button.
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
if hovered {
|
||||
if hovered && press.hover < 0.04 && press.down < 0.04 {
|
||||
// The remove button sits in the row, so the row response may not be hovered.
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
|
||||
} else {
|
||||
press.wash(ui, rect, 8, false);
|
||||
}
|
||||
if hovered {
|
||||
// Remove just this file from the list (#430); the file itself is untouched.
|
||||
let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0));
|
||||
let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click());
|
||||
@@ -150,26 +171,27 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
remove = Some(r.path.clone());
|
||||
}
|
||||
}
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(rect.min + vec2(10.0, 11.0), vec2(24.0, 24.0)),
|
||||
Rect::from_min_size(body.min + vec2(10.0, 11.0), vec2(24.0, 24.0)),
|
||||
"file-text",
|
||||
22.0,
|
||||
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
|
||||
);
|
||||
// Room on the right for the remove button.
|
||||
let detail = ui.painter().text(
|
||||
rect.right_center() - vec2(48.0, 0.0),
|
||||
body.right_center() - vec2(48.0, 0.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("{} · {}", crate::i18n::trn(crate::i18n::current(), r.pages as u64, "{n} page", "{n} pages"), human_size(r.size)),
|
||||
theme::regular(12.0),
|
||||
t.text_muted,
|
||||
);
|
||||
// The name and path stop short of the page count; hovering shows the whole path.
|
||||
let width = detail.left() - 16.0 - (rect.left() + 46.0);
|
||||
widgets::row_text(ui, rect.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width);
|
||||
widgets::row_text(ui, rect.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width);
|
||||
if resp.on_hover_text(&r.path).clicked() {
|
||||
let width = detail.left() - 16.0 - (body.left() + 46.0);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width);
|
||||
if theme::hand(resp).on_hover_text(&r.path).clicked() {
|
||||
open = Some(r.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
Create from إنشاء من
|
||||
Single file ملف واحد
|
||||
Multiple files عدة ملفات
|
||||
Separate PDFs ملفات PDF منفصلة
|
||||
Images صور
|
||||
Clipboard الحافظة
|
||||
Blank page صفحة فارغة
|
||||
@@ -1869,6 +1870,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} تغيير {key}
|
||||
Insert pages from {name} إدراج صفحات من {name}
|
||||
Insert pages from {name} — 1 field renamed إدراج صفحات من {name} — تمت إعادة تسمية حقل واحد
|
||||
Insert pages from {name} — fields renamed إدراج صفحات من {name} — تمت إعادة تسمية حقول
|
||||
Fill in {name} تعبئة {name}
|
||||
Set the image of {name} تعيين صورة {name}
|
||||
Edit script of {name} تحرير سكربت {name}
|
||||
@@ -2035,7 +2038,9 @@
|
||||
Couldn't import the signature image: {e} تعذّر استيراد صورة التوقيع: {e}
|
||||
@plural {n} page|{n} pages {n} صفحة|{n} صفحة|{n} صفحتان|{n} صفحات|{n} صفحة|{n} صفحة
|
||||
@plural {n} field|{n} fields {n} حقل|{n} حقل|{n} حقلان|{n} حقول|{n} حقلًا|{n} حقل
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} يتعارض {n} من أسماء الحقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض اسم {n} حقل مع الملفات السابقة وسيتم تغيير اسمه: {list}|يتعارض اسما {n} حقل مع الملفات السابقة وسيتم تغيير اسميهما: {list}|تتعارض أسماء {n} حقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض {n} اسم حقل مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض {n} من أسماء الحقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}
|
||||
Create PDF from multiple files… إنشاء PDF من عدة ملفات…
|
||||
Create separate PDFs… إنشاء ملفات PDF منفصلة…
|
||||
Optimizing… removing user data جارٍ التحسين… إزالة بيانات المستخدم
|
||||
Optimizing… image {d} of {t} جارٍ التحسين… الصورة {d} من {t}
|
||||
Optimizing… cleaning up جارٍ التحسين… التنظيف
|
||||
@@ -2288,3 +2293,16 @@ smooth scrolling Off معطّل
|
||||
Tools panel side جانب لوحة الأدوات
|
||||
Add comment إضافة تعليق
|
||||
Change tag properties تغيير خصائص الوسم
|
||||
Left out: {names} تم استبعاد: {names}
|
||||
already a PDF ملف PDF بالفعل
|
||||
no unused output name لا يوجد اسم ناتج غير مستخدم
|
||||
no output folder was chosen لم يتم اختيار مجلد للإخراج
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs تم تنزيل {n} من ملفات PDF|تم تنزيل ملف PDF واحد ({n})|تم تنزيل ملفي PDF ({n})|تم تنزيل {n} ملفات PDF|تم تنزيل {n} ملف PDF|تم تنزيل {n} من ملفات PDF
|
||||
Scan مسح ضوئي
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق.
|
||||
Choose photo… اختيار صورة…
|
||||
No photo chosen لم تُختر صورة
|
||||
Photo صورة
|
||||
Ink sensitivity حساسية الحبر
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. لم يُعثر على توقيع داخل الإطار. حرّك الإطار فوق التوقيع أو ارفع حساسية الحبر.
|
||||
Couldn't use the photo: {e} تعذّر استخدام الصورة: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -88,6 +88,7 @@
|
||||
Create PDF from file… Създай PDF от файл…
|
||||
Create PDF from images… Създай PDF от изображения…
|
||||
Create PDF from multiple files… Създай PDF от няколко файла…
|
||||
Create separate PDFs… Създаване на отделни PDF файлове…
|
||||
Create PDF from clipboard Създай PDF от клипборда
|
||||
Combine files… Обедини файлове…
|
||||
Save Запиши
|
||||
@@ -951,6 +952,7 @@
|
||||
Bring your own model (off by default) Използвайте собствен модел (изключено по подразбиране)
|
||||
Single file Един файл
|
||||
Multiple files Няколко файла
|
||||
Separate PDFs Отделни PDF файлове
|
||||
Images Изображения
|
||||
Clipboard Клипборд
|
||||
Blank page Празна страница
|
||||
@@ -1960,6 +1962,8 @@
|
||||
Images and text (converted to PDF) Изображения и текст (конвертирани в PDF)
|
||||
Import {name} Импортирай {name}
|
||||
Insert pages from {name} Вмъкни страници от {name}
|
||||
Insert pages from {name} — 1 field renamed Вмъкни страници от {name} — 1 поле е преименувано
|
||||
Insert pages from {name} — fields renamed Вмъкни страници от {name} — полета са преименувани
|
||||
Items marked Ready work today. Елементите, отбелязани като „Готово“, работят още сега.
|
||||
JavaScript JavaScript
|
||||
JavaScript, e.g. getField("total").value JavaScript, напр. getField("total").value
|
||||
@@ -2080,6 +2084,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA формуляр: полетата и XFA данните му се поддържат синхронизирани, така че другите програми за преглед показват попълненото от вас.
|
||||
@plural {n} page|{n} pages {n} страница|{n} страници
|
||||
@plural {n} field|{n} fields {n} поле|{n} полета
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Име на {n} поле се разминава с по-ранните файлове и ще бъде преименувано: {list}|Имена на {n} полета се разминават с по-ранните файлове и ще бъдат преименувани: {list}
|
||||
Created a PDF from {n} files Създаден е PDF от {n} файла
|
||||
PDF, images and text PDF, изображения и текст
|
||||
Insert a file before page {label} Вмъкни файл преди страница {label}
|
||||
@@ -2230,3 +2235,16 @@ smooth scrolling Off Изключено
|
||||
Tools panel side Страна на панела с инструменти
|
||||
Add comment Добавяне на коментар
|
||||
Change tag properties Промяна на свойствата на етикета
|
||||
Left out: {names} Пропуснати: {names}
|
||||
already a PDF вече е PDF
|
||||
no unused output name няма свободно име за изходния файл
|
||||
no output folder was chosen не е избрана папка за изхода
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Изтеглен е {n} PDF|Изтеглени са {n} PDF файла
|
||||
Scan Сканиране
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията.
|
||||
Choose photo… Избор на снимка…
|
||||
No photo chosen Няма избрана снимка
|
||||
Photo Снимка
|
||||
Ink sensitivity Чувствителност към мастило
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамката не е открит подпис. Преместете рамката върху подписа или увеличете чувствителността към мастило.
|
||||
Couldn't use the photo: {e} Снимката не може да се използва: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -54,6 +54,7 @@
|
||||
Create PDF from file… Vytvořit PDF ze souboru…
|
||||
Create PDF from images… Vytvořit PDF z obrázků…
|
||||
Create PDF from multiple files… Vytvořit PDF z více souborů…
|
||||
Create separate PDFs… Vytvořit samostatná PDF…
|
||||
Create PDF from clipboard Vytvořit PDF ze schránky
|
||||
Combine files… Sloučit soubory…
|
||||
Save Uložit
|
||||
@@ -171,6 +172,7 @@
|
||||
|
||||
# Multi-object selection and movement (#844)
|
||||
@plural {n} object selected|{n} objects selected Vybrán {n} objekt|Vybrány {n} objekty|Vybráno {n} objektů
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} název pole koliduje s dřívějšími soubory a bude přejmenován: {list}|Názvy {n} polí kolidují s dřívějšími soubory a budou přejmenovány: {list}|{n} názvů polí koliduje s dřívějšími soubory a bude přejmenováno: {list}
|
||||
Drag the selection to move it. Esc clears the selection. Přetažením výběr přesunete. Esc zruší výběr.
|
||||
Shift-click to select several objects. Drag empty space to select an area. Kliknutím se Shiftem vyberete více objektů. Tažením v prázdném místě vyberete oblast.
|
||||
Select at most 1000 objects at a time. Vyberte nejvýše 1000 objektů najednou.
|
||||
@@ -426,6 +428,7 @@ key Space Mezerník
|
||||
Create from Vytvořit z
|
||||
Single file Jeden soubor
|
||||
Multiple files Více souborů
|
||||
Separate PDFs Samostatná PDF
|
||||
Images Obrázky
|
||||
Clipboard Schránka
|
||||
Blank page Prázdná stránka
|
||||
@@ -1916,6 +1919,8 @@ key Space Mezerník
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Změnit: {key}
|
||||
Insert pages from {name} Vložit stránky ze souboru {name}
|
||||
Insert pages from {name} — 1 field renamed Vložit stránky ze souboru {name} — 1 pole přejmenováno
|
||||
Insert pages from {name} — fields renamed Vložit stránky ze souboru {name} — pole přejmenována
|
||||
Fill in {name} Vyplnit {name}
|
||||
Set the image of {name} Nastavit obrázek pole {name}
|
||||
Edit script of {name} Upravit skript {name}
|
||||
@@ -2341,3 +2346,16 @@ smooth scrolling Off Vypnuto
|
||||
Tools panel side Strana panelu nástrojů
|
||||
Add comment Přidat komentář
|
||||
Change tag properties Změnit vlastnosti značky
|
||||
Left out: {names} Vynecháno: {names}
|
||||
already a PDF již je PDF
|
||||
no unused output name žádný volný název výstupního souboru
|
||||
no output folder was chosen nebyla vybrána výstupní složka
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Stažen {n} soubor PDF|Staženy {n} soubory PDF|Staženo {n} souborů PDF
|
||||
Scan Skenovat
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír.
|
||||
Choose photo… Vybrat fotografii…
|
||||
No photo chosen Není vybrána žádná fotografie
|
||||
Photo Fotografie
|
||||
Ink sensitivity Citlivost na inkoust
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V rámečku nebyl nalezen žádný podpis. Přesuňte rámeček na podpis nebo zvyšte citlivost na inkoust.
|
||||
Couldn't use the photo: {e} Fotografii nelze použít: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -28,6 +28,7 @@
|
||||
Create PDF from file… PDF aus Datei erstellen…
|
||||
Create PDF from images… PDF aus Bildern erstellen…
|
||||
Create PDF from multiple files… PDF aus mehreren Dateien erstellen…
|
||||
Create separate PDFs… Separate PDFs erstellen…
|
||||
Create PDF from clipboard PDF aus Zwischenablage erstellen
|
||||
Combine files… Dateien zusammenführen…
|
||||
Save Speichern
|
||||
@@ -319,6 +320,7 @@
|
||||
Create from Erstellen aus
|
||||
Single file Einzelne Datei
|
||||
Multiple files Mehrere Dateien
|
||||
Separate PDFs Einzelne PDFs
|
||||
Images Bilder
|
||||
Clipboard Zwischenablage
|
||||
Blank page Leere Seite
|
||||
@@ -1804,6 +1806,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} {key} ändern
|
||||
Insert pages from {name} Seiten aus {name} einfügen
|
||||
Insert pages from {name} — 1 field renamed Seiten aus {name} einfügen — 1 Feld umbenannt
|
||||
Insert pages from {name} — fields renamed Seiten aus {name} einfügen — Felder umbenannt
|
||||
Fill in {name} {name} ausfüllen
|
||||
Set the image of {name} Bild von {name} festlegen
|
||||
Edit script of {name} Skript von {name} bearbeiten
|
||||
@@ -1961,6 +1965,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-Formular: Seine Felder und XFA-Daten werden synchron gehalten, damit andere Viewer anzeigen, was du ausfüllst.
|
||||
@plural {n} page|{n} pages {n} Seite|{n} Seiten
|
||||
@plural {n} field|{n} fields {n} Feld|{n} Felder
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} Feldname kollidiert mit früheren Dateien und wird umbenannt: {list}|{n} Feldnamen kollidieren mit früheren Dateien und werden umbenannt: {list}
|
||||
Switch to two-page view first to show the cover page Wechsle zuerst zur Doppelseitenansicht, um das Deckblatt anzuzeigen
|
||||
Page display: {layout} Seitenanzeige: {layout}
|
||||
Fit to width scrolling An Breite anpassen und scrollen
|
||||
@@ -2268,3 +2273,16 @@ smooth scrolling Off Aus
|
||||
Tools panel side Seite des Werkzeugbereichs
|
||||
Add comment Kommentar hinzufügen
|
||||
Change tag properties Tag-Eigenschaften ändern
|
||||
Left out: {names} Ausgelassen: {names}
|
||||
already a PDF bereits ein PDF
|
||||
no unused output name kein freier Ausgabename
|
||||
no output folder was chosen kein Ausgabeordner gewählt
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF heruntergeladen|{n} PDFs heruntergeladen
|
||||
Scan Scannen
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier.
|
||||
Choose photo… Foto auswählen…
|
||||
No photo chosen Kein Foto ausgewählt
|
||||
Photo Foto
|
||||
Ink sensitivity Tintenempfindlichkeit
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Im Rahmen wurde keine Unterschrift gefunden. Ziehen Sie den Rahmen über die Unterschrift oder erhöhen Sie die Tintenempfindlichkeit.
|
||||
Couldn't use the photo: {e} Das Foto kann nicht verwendet werden: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -42,6 +42,7 @@
|
||||
Create PDF from file… Δημιουργία PDF από αρχείο…
|
||||
Create PDF from images… Δημιουργία PDF από εικόνες…
|
||||
Create PDF from multiple files… Δημιουργία PDF από πολλά αρχεία…
|
||||
Create separate PDFs… Δημιουργία ξεχωριστών PDF…
|
||||
Create PDF from clipboard Δημιουργία PDF από το πρόχειρο
|
||||
Combine files… Συνένωση αρχείων…
|
||||
Save Αποθήκευση
|
||||
@@ -1305,6 +1306,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Αλλαγή {key}
|
||||
Insert pages from {name} Εισαγωγή σελίδων από {name}
|
||||
Insert pages from {name} — 1 field renamed Εισαγωγή σελίδων από {name} — μετονομάστηκε 1 πεδίο
|
||||
Insert pages from {name} — fields renamed Εισαγωγή σελίδων από {name} — μετονομάστηκαν πεδία
|
||||
Fill in {name} Συμπλήρωση του {name}
|
||||
Set the image of {name} Ορισμός εικόνας του {name}
|
||||
Edit script of {name} Επεξεργασία δέσμης ενεργειών του {name}
|
||||
@@ -1689,6 +1692,7 @@
|
||||
Create from Δημιουργία από
|
||||
Single file Ένα αρχείο
|
||||
Multiple files Πολλά αρχεία
|
||||
Separate PDFs Ξεχωριστά PDF
|
||||
Clipboard Πρόχειρο
|
||||
Blank page Κενή σελίδα
|
||||
Combine files Συνδυασμός αρχείων
|
||||
@@ -2283,3 +2287,17 @@ smooth scrolling Off Ανενεργή
|
||||
Tools panel side Πλευρά πλαισίου εργαλείων
|
||||
Add comment Προσθήκη σχολίου
|
||||
Change tag properties Αλλαγή ιδιοτήτων ετικέτας
|
||||
Left out: {names} Παραλείφθηκαν: {names}
|
||||
already a PDF είναι ήδη PDF
|
||||
no unused output name δεν υπάρχει διαθέσιμο όνομα εξόδου
|
||||
no output folder was chosen δεν επιλέχθηκε φάκελος εξόδου
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Λήφθηκε {n} PDF|Λήφθηκαν {n} PDF
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list}
|
||||
Scan Σάρωση
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί.
|
||||
Choose photo… Επιλογή φωτογραφίας…
|
||||
No photo chosen Δεν επιλέχθηκε φωτογραφία
|
||||
Photo Φωτογραφία
|
||||
Ink sensitivity Ευαισθησία μελανιού
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Δεν βρέθηκε υπογραφή στο πλαίσιο. Μετακινήστε το πλαίσιο πάνω από την υπογραφή ή αυξήστε την ευαισθησία μελανιού.
|
||||
Couldn't use the photo: {e} Δεν ήταν δυνατή η χρήση της φωτογραφίας: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 479 and column 12.
|
@@ -51,6 +51,7 @@
|
||||
Create PDF from file… Crear PDF a partir de un archivo…
|
||||
Create PDF from images… Crear PDF a partir de imágenes…
|
||||
Create PDF from multiple files… Crear PDF a partir de varios archivos…
|
||||
Create separate PDFs… Crear PDF separados…
|
||||
Create PDF from clipboard Crear PDF a partir del portapapeles
|
||||
Combine files… Combinar archivos…
|
||||
Save Guardar
|
||||
@@ -342,6 +343,7 @@
|
||||
Create from Crear a partir de
|
||||
Single file Un solo archivo
|
||||
Multiple files Varios archivos
|
||||
Separate PDFs PDF separados
|
||||
Images Imágenes
|
||||
Clipboard Portapapeles
|
||||
Blank page Página en blanco
|
||||
@@ -1883,6 +1885,8 @@ action wizard Start Iniciar
|
||||
{month} {y} {month} de {y}
|
||||
Change {key} Cambiar {key}
|
||||
Insert pages from {name} Insertar páginas de {name}
|
||||
Insert pages from {name} — 1 field renamed Insertar páginas de {name} — 1 campo renombrado
|
||||
Insert pages from {name} — fields renamed Insertar páginas de {name} — campos renombrados
|
||||
Fill in {name} Rellenar {name}
|
||||
Set the image of {name} Definir la imagen de {name}
|
||||
Edit script of {name} Editar el script de {name}
|
||||
@@ -2041,6 +2045,7 @@ action wizard Start Iniciar
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulario XFA: sus campos y sus datos XFA se mantienen sincronizados, para que otros visores muestren lo que rellenes.
|
||||
@plural {n} page|{n} pages {n} página|{n} páginas
|
||||
@plural {n} field|{n} fields {n} campo|{n} campos
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nombre de campo coincide con el de archivos anteriores y se renombrará: {list}|{n} nombres de campo coinciden con los de archivos anteriores y se renombrarán: {list}
|
||||
Switch to two-page view first to show the cover page Cambia primero a la vista de dos páginas para mostrar la portada
|
||||
Page display: {layout} Presentación de página: {layout}
|
||||
Fit to width scrolling Desplazamiento ajustado al ancho
|
||||
@@ -2397,3 +2402,16 @@ smooth scrolling Off Desactivado
|
||||
Tools panel side Lado del panel de herramientas
|
||||
Add comment Añadir comentario
|
||||
Change tag properties Cambiar propiedades de la etiqueta
|
||||
Left out: {names} Omitidos: {names}
|
||||
already a PDF ya es un PDF
|
||||
no unused output name no hay un nombre de salida libre
|
||||
no output folder was chosen no se eligió ninguna carpeta de salida
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Se descargó {n} PDF|Se descargaron {n} PDF
|
||||
Scan Escanear
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firme en una hoja de papel blanco y hágale una foto con luz intensa y uniforme (use el flash). Elija la foto o suéltela aquí: PdfCraft encuentra la firma y elimina el papel.
|
||||
Choose photo… Elegir foto…
|
||||
No photo chosen No se ha elegido ninguna foto
|
||||
Photo Foto
|
||||
Ink sensitivity Sensibilidad de la tinta
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. No se encontró ninguna firma en el marco. Mueva el marco sobre la firma o aumente la sensibilidad de la tinta.
|
||||
Couldn't use the photo: {e} No se puede usar la foto: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -28,6 +28,7 @@
|
||||
Create PDF from file… Créer un PDF à partir d'un fichier…
|
||||
Create PDF from images… Créer un PDF à partir d'images…
|
||||
Create PDF from multiple files… Créer un PDF à partir de plusieurs fichiers…
|
||||
Create separate PDFs… Créer des PDF séparés…
|
||||
Create PDF from clipboard Créer un PDF à partir du presse-papiers
|
||||
Combine files… Combiner des fichiers…
|
||||
Save Enregistrer
|
||||
@@ -319,6 +320,7 @@
|
||||
Create from Créer à partir de
|
||||
Single file Un seul fichier
|
||||
Multiple files Plusieurs fichiers
|
||||
Separate PDFs PDF séparés
|
||||
Images Images
|
||||
Clipboard Presse-papiers
|
||||
Blank page Page vierge
|
||||
@@ -1799,6 +1801,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Modifier {key}
|
||||
Insert pages from {name} Insérer des pages depuis {name}
|
||||
Insert pages from {name} — 1 field renamed Insérer des pages depuis {name} — 1 champ renommé
|
||||
Insert pages from {name} — fields renamed Insérer des pages depuis {name} — champs renommés
|
||||
Fill in {name} Remplir {name}
|
||||
Set the image of {name} Définir l'image de {name}
|
||||
Edit script of {name} Modifier le script de {name}
|
||||
@@ -1956,6 +1960,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulaire XFA : ses champs et ses données XFA restent synchronisés, afin que les autres visionneuses affichent ce que vous remplissez.
|
||||
@plural {n} page|{n} pages {n} page|{n} pages
|
||||
@plural {n} field|{n} fields {n} champ|{n} champs
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nom de champ entre en collision avec les fichiers précédents et sera renommé : {list}|{n} noms de champs entrent en collision avec les fichiers précédents et seront renommés : {list}
|
||||
Switch to two-page view first to show the cover page Passez d'abord à l'affichage sur deux pages pour montrer la couverture
|
||||
Page display: {layout} Affichage des pages : {layout}
|
||||
Fit to width scrolling Défilement ajusté à la largeur
|
||||
@@ -2267,3 +2272,16 @@ smooth scrolling Off Désactivé
|
||||
Tools panel side Côté du panneau Outils
|
||||
Add comment Ajouter un commentaire
|
||||
Change tag properties Modifier les propriétés de la balise
|
||||
Left out: {names} Ignorés : {names}
|
||||
already a PDF déjà un PDF
|
||||
no unused output name aucun nom de sortie libre
|
||||
no output folder was chosen aucun dossier de sortie choisi
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF téléchargé|{n} PDF téléchargés
|
||||
Scan Numériser
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier.
|
||||
Choose photo… Choisir une photo…
|
||||
No photo chosen Aucune photo choisie
|
||||
Photo Photo
|
||||
Ink sensitivity Sensibilité à l’encre
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Aucune signature trouvée dans le cadre. Placez le cadre sur la signature ou augmentez la sensibilité à l’encre.
|
||||
Couldn't use the photo: {e} Impossible d’utiliser la photo : {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -28,6 +28,7 @@
|
||||
Create PDF from file… PDF létrehozása fájlból…
|
||||
Create PDF from images… PDF létrehozása képekből…
|
||||
Create PDF from multiple files… PDF létrehozása több fájlból…
|
||||
Create separate PDFs… Külön PDF-ek létrehozása…
|
||||
Create PDF from clipboard PDF létrehozása a vágólapról
|
||||
Combine files… Fájlok egyesítése…
|
||||
Save Mentés
|
||||
@@ -317,6 +318,7 @@
|
||||
Create from Létrehozás ebből
|
||||
Single file Egy fájl
|
||||
Multiple files Több fájl
|
||||
Separate PDFs Külön PDF-ek
|
||||
Images Képek
|
||||
Clipboard Vágólap
|
||||
Blank page Üres oldal
|
||||
@@ -1789,6 +1791,8 @@
|
||||
{month} {y} {y}. {month}
|
||||
Change {key} {key} módosítása
|
||||
Insert pages from {name} Oldalak beszúrása ebből: {name}
|
||||
Insert pages from {name} — 1 field renamed Oldalak beszúrása ebből: {name} — 1 mező átnevezve
|
||||
Insert pages from {name} — fields renamed Oldalak beszúrása ebből: {name} — mezők átnevezve
|
||||
Fill in {name} {name} kitöltése
|
||||
Set the image of {name} {name} képének beállítása
|
||||
Edit script of {name} {name} szkriptjének szerkesztése
|
||||
@@ -1946,6 +1950,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-űrlap: a mezői és az XFA-adatai együtt maradnak, így más megjelenítők azt mutatják, amit beírsz.
|
||||
@plural {n} page|{n} pages {n} oldal|{n} oldal
|
||||
@plural {n} field|{n} fields {n} mező|{n} mező
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} mezőnév ütközik a korábbi fájlokkal, és át lesz nevezve: {list}|{n} mezőnév ütközik a korábbi fájlokkal, és át lesz nevezve: {list}
|
||||
Switch to two-page view first to show the cover page Előbb válts kétoldalas nézetre a borító megjelenítéséhez
|
||||
Page display: {layout} Oldalmegjelenítés: {layout}
|
||||
Fit to width scrolling Görgetés szélességhez igazítva
|
||||
@@ -2266,3 +2271,16 @@ smooth scrolling Off Ki
|
||||
Tools panel side Az Eszközök panel oldala
|
||||
Add comment Megjegyzés hozzáadása
|
||||
Change tag properties Címke tulajdonságainak módosítása
|
||||
Left out: {names} Kihagyva: {names}
|
||||
already a PDF már PDF
|
||||
no unused output name nincs szabad kimeneti név
|
||||
no output folder was chosen nincs kiválasztva kimeneti mappa
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF letöltve|{n} PDF letöltve
|
||||
Scan Beolvasás
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt.
|
||||
Choose photo… Fénykép kiválasztása…
|
||||
No photo chosen Nincs kiválasztott fénykép
|
||||
Photo Fénykép
|
||||
Ink sensitivity Tintaérzékenység
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. A keretben nem található aláírás. Húzza a keretet az aláírásra, vagy növelje a tintaérzékenységet.
|
||||
Couldn't use the photo: {e} A fénykép nem használható: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -54,6 +54,7 @@
|
||||
Create PDF from file… Crea PDF da file…
|
||||
Create PDF from images… Crea PDF da immagini…
|
||||
Create PDF from multiple files… Crea PDF da più file…
|
||||
Create separate PDFs… Crea PDF separati…
|
||||
Create PDF from clipboard Crea PDF dagli appunti
|
||||
Combine files… Combina file…
|
||||
Save Salva
|
||||
@@ -343,6 +344,7 @@
|
||||
Create from Crea da
|
||||
Single file File singolo
|
||||
Multiple files Più file
|
||||
Separate PDFs PDF separati
|
||||
Images Immagini
|
||||
Clipboard Appunti
|
||||
Blank page Pagina vuota
|
||||
@@ -1874,6 +1876,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Modifica {key}
|
||||
Insert pages from {name} Inserisci pagine da {name}
|
||||
Insert pages from {name} — 1 field renamed Inserisci pagine da {name} — 1 campo rinominato
|
||||
Insert pages from {name} — fields renamed Inserisci pagine da {name} — campi rinominati
|
||||
Fill in {name} Compila {name}
|
||||
Set the image of {name} Imposta l'immagine di {name}
|
||||
Edit script of {name} Modifica script di {name}
|
||||
@@ -2086,6 +2090,7 @@
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{bin_added} / −{bin_deleted} risorse binarie, {first} – {last}
|
||||
@plural {n} page|{n} pages {n} pagina|{n} pagine
|
||||
@plural {n} field|{n} fields {n} campo|{n} campi
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nome di campo è in conflitto con i file precedenti e verrà rinominato: {list}|{n} nomi di campo sono in conflitto con i file precedenti e verranno rinominati: {list}
|
||||
|
||||
# Labels added on main after this catalog was written (merged 2026-10-09)
|
||||
A button Un pulsante
|
||||
@@ -2276,3 +2281,16 @@ smooth scrolling Off Disattivato
|
||||
Tools panel side Lato del pannello Strumenti
|
||||
Add comment Aggiungi commento
|
||||
Change tag properties Modifica proprietà del tag
|
||||
Left out: {names} Esclusi: {names}
|
||||
already a PDF è già un PDF
|
||||
no unused output name nessun nome di output libero
|
||||
no output folder was chosen nessuna cartella di output scelta
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Scaricato {n} PDF|Scaricati {n} PDF
|
||||
Scan Scansiona
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta.
|
||||
Choose photo… Scegli foto…
|
||||
No photo chosen Nessuna foto scelta
|
||||
Photo Foto
|
||||
Ink sensitivity Sensibilità all’inchiostro
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nessuna firma trovata nella cornice. Sposta la cornice sulla firma o aumenta la sensibilità all’inchiostro.
|
||||
Couldn't use the photo: {e} Impossibile usare la foto: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -57,6 +57,7 @@
|
||||
Create PDF from file… ファイルから PDF を作成…
|
||||
Create PDF from images… 画像から PDF を作成…
|
||||
Create PDF from multiple files… 複数のファイルから PDF を作成…
|
||||
Create separate PDFs… 個別の PDF を作成…
|
||||
Create PDF from clipboard クリップボードから PDF を作成
|
||||
Combine files… ファイルを結合…
|
||||
Save 保存
|
||||
@@ -346,6 +347,7 @@
|
||||
Create from 作成元
|
||||
Single file 単一ファイル
|
||||
Multiple files 複数のファイル
|
||||
Separate PDFs 個別の PDF
|
||||
Images 画像
|
||||
Clipboard クリップボード
|
||||
Blank page 空白のページ
|
||||
@@ -1880,6 +1882,8 @@
|
||||
{month} {y} {y}年{month}
|
||||
Change {key} {key}を変更
|
||||
Insert pages from {name} {name} からページを挿入
|
||||
Insert pages from {name} — 1 field renamed {name} からページを挿入 — 1 個のフィールド名を変更
|
||||
Insert pages from {name} — fields renamed {name} からページを挿入 — フィールド名を変更
|
||||
Fill in {name} {name} に入力
|
||||
Set the image of {name} {name} の画像を設定
|
||||
Edit script of {name} {name} のスクリプトを編集
|
||||
@@ -2159,6 +2163,7 @@
|
||||
−Bin −Bin
|
||||
−LOC −LOC
|
||||
@plural {n} field|{n} fields {n} 個のフィールド
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 個のフィールド名が前のファイルと競合するため、名前が変更されます: {list}
|
||||
@plural {n} page|{n} pages {n} ページ
|
||||
signature pad Type 入力
|
||||
Flatten Fill & Sign when saving 保存時に入力と署名を統合する
|
||||
@@ -2267,3 +2272,16 @@ smooth scrolling Off オフ
|
||||
Tools panel side ツールパネルの位置
|
||||
Add comment コメントを追加
|
||||
Change tag properties タグのプロパティを変更
|
||||
Left out: {names} 除外: {names}
|
||||
already a PDF すでに PDF です
|
||||
no unused output name 使用可能な出力名がありません
|
||||
no output folder was chosen 出力フォルダーが選択されていません
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} 個の PDF をダウンロードしました
|
||||
Scan スキャン
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。
|
||||
Choose photo… 写真を選択…
|
||||
No photo chosen 写真が選択されていません
|
||||
Photo 写真
|
||||
Ink sensitivity インクの感度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 枠内に署名が見つかりません。枠を署名に合わせるか、インクの感度を上げてください。
|
||||
Couldn't use the photo: {e} 写真を使用できません: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -307,6 +307,7 @@
|
||||
Create from Мынадан жасау
|
||||
Single file Бір файл
|
||||
Multiple files Бірнеше файл
|
||||
Separate PDFs Бөлек PDF файлдары
|
||||
Images Кескіндер
|
||||
Clipboard Алмасу буфері
|
||||
Blank page Бос бет
|
||||
@@ -1795,6 +1796,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Өзгерту: {key}
|
||||
Insert pages from {name} Беттерді {name} файлынан енгізу
|
||||
Insert pages from {name} — 1 field renamed {name} ішінен беттерді кірістіру — 1 өріс қайта аталды
|
||||
Insert pages from {name} — fields renamed {name} ішінен беттерді кірістіру — өрістер қайта аталды
|
||||
Fill in {name} {name} толтыру
|
||||
Set the image of {name} {name} кескінін орнату
|
||||
Edit script of {name} {name} скриптін түзету
|
||||
@@ -1962,6 +1965,7 @@
|
||||
@plural {n} page|{n} pages {n} бет|{n} бет
|
||||
@plural {n} field|{n} fields {n} өріс|{n} өріс
|
||||
Create PDF from multiple files… Бірнеше файлдан PDF жасау…
|
||||
Create separate PDFs… Бөлек PDF файлдарын жасау…
|
||||
Optimizing… removing user data Оңтайландыру… пайдаланушы деректерін жою
|
||||
Optimizing… image {d} of {t} Оңтайландыру… сурет {d} / {t}
|
||||
Optimizing… cleaning up Оңтайландыру… тазарту
|
||||
@@ -2265,3 +2269,17 @@ smooth scrolling Off Өшірулі
|
||||
Tools panel side Құралдар панелінің жағы
|
||||
Add comment Түсініктеме қосу
|
||||
Change tag properties Тег қасиеттерін өзгерту
|
||||
Left out: {names} Қалдырылды: {names}
|
||||
already a PDF бұл қазірдің өзінде PDF
|
||||
no unused output name бос шығыс атауы жоқ
|
||||
no output folder was chosen шығыс қалтасы таңдалмады
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF жүктелді|{n} PDF жүктелді
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}
|
||||
Scan Сканерлеу
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды.
|
||||
Choose photo… Суретті таңдау…
|
||||
No photo chosen Сурет таңдалмаған
|
||||
Photo Сурет
|
||||
Ink sensitivity Сия сезімталдығы
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Жақтауда қолтаңба табылмады. Жақтауды қолтаңбаның үстіне жылжытыңыз немесе сия сезімталдығын арттырыңыз.
|
||||
Couldn't use the photo: {e} Суретті пайдалану мүмкін болмады: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 819 and column 12.
|
@@ -54,6 +54,7 @@
|
||||
Create PDF from file… Utwórz PDF z pliku…
|
||||
Create PDF from images… Utwórz PDF z obrazów…
|
||||
Create PDF from multiple files… Utwórz PDF z wielu plików…
|
||||
Create separate PDFs… Utwórz osobne pliki PDF…
|
||||
Create PDF from clipboard Utwórz PDF ze schowka
|
||||
Combine files… Połącz pliki…
|
||||
Save Zapisz
|
||||
@@ -341,6 +342,7 @@
|
||||
Create from Utwórz z
|
||||
Single file Pojedynczy plik
|
||||
Multiple files Wiele plików
|
||||
Separate PDFs Osobne pliki PDF
|
||||
Images Obrazy
|
||||
Clipboard Schowek
|
||||
Blank page Pusta strona
|
||||
@@ -1861,6 +1863,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Zmień {key}
|
||||
Insert pages from {name} Wstaw strony z {name}
|
||||
Insert pages from {name} — 1 field renamed Wstaw strony z {name} — zmieniono nazwę 1 pola
|
||||
Insert pages from {name} — fields renamed Wstaw strony z {name} — zmieniono nazwy pól
|
||||
Fill in {name} Wypełnij {name}
|
||||
Set the image of {name} Ustaw obraz pola {name}
|
||||
Edit script of {name} Edytuj skrypt pola {name}
|
||||
@@ -2138,6 +2142,7 @@
|
||||
−Bin −Bin
|
||||
−LOC −LOC
|
||||
@plural {n} field|{n} fields {n} pole|{n} pola|{n} pól
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Nazwa {n} pola koliduje z wcześniejszymi plikami i zostanie zmieniona: {list}|Nazwy {n} pól kolidują z wcześniejszymi plikami i zostaną zmienione: {list}|{n} nazw pól koliduje z wcześniejszymi plikami i zostanie zmienionych: {list}
|
||||
@plural {n} page|{n} pages {n} strona|{n} strony|{n} stron
|
||||
signature pad Type Wpisz
|
||||
Flatten Fill & Sign when saving Spłaszczaj elementy Wypełnij i podpisz przy zapisie
|
||||
@@ -2247,3 +2252,16 @@ smooth scrolling Off Wyłączone
|
||||
Tools panel side Strona panelu narzędzi
|
||||
Add comment Dodaj komentarz
|
||||
Change tag properties Zmień właściwości znacznika
|
||||
Left out: {names} Pominięto: {names}
|
||||
already a PDF to już jest PDF
|
||||
no unused output name brak wolnej nazwy pliku wyjściowego
|
||||
no output folder was chosen nie wybrano folderu wyjściowego
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Pobrano {n} plik PDF|Pobrano {n} pliki PDF|Pobrano {n} plików PDF
|
||||
Scan Skanuj
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier.
|
||||
Choose photo… Wybierz zdjęcie…
|
||||
No photo chosen Nie wybrano zdjęcia
|
||||
Photo Zdjęcie
|
||||
Ink sensitivity Czułość na atrament
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. W ramce nie znaleziono podpisu. Przesuń ramkę na podpis lub zwiększ czułość na atrament.
|
||||
Couldn't use the photo: {e} Nie można użyć zdjęcia: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -35,6 +35,7 @@
|
||||
Create PDF from file… Criar PDF a partir de arquivo…
|
||||
Create PDF from images… Criar PDF a partir de imagens…
|
||||
Create PDF from multiple files… Criar PDF a partir de vários arquivos…
|
||||
Create separate PDFs… Criar PDFs separados…
|
||||
Create PDF from clipboard Criar PDF a partir da área de transferência
|
||||
Combine files… Combinar arquivos…
|
||||
Save Salvar
|
||||
@@ -895,6 +896,7 @@
|
||||
Bring your own model (off by default) Traga seu próprio modelo (desativado por padrão)
|
||||
Single file Arquivo único
|
||||
Multiple files Vários arquivos
|
||||
Separate PDFs PDFs separados
|
||||
Images Imagens
|
||||
Clipboard Área de transferência
|
||||
Blank page Página em branco
|
||||
@@ -1832,6 +1834,8 @@
|
||||
Images and text (converted to PDF) Imagens e texto (convertidos para PDF)
|
||||
Import {name} Importar {name}
|
||||
Insert pages from {name} Inserir páginas de {name}
|
||||
Insert pages from {name} — 1 field renamed Inserir páginas de {name} — 1 campo renomeado
|
||||
Insert pages from {name} — fields renamed Inserir páginas de {name} — campos renomeados
|
||||
Items marked Ready work today. Os itens marcados como Pronto já funcionam hoje.
|
||||
JavaScript JavaScript
|
||||
JavaScript, e.g. getField("total").value JavaScript, ex.: getField("total").value
|
||||
@@ -1942,6 +1946,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulário XFA: seus campos e seus dados XFA são mantidos sincronizados, para que outros leitores exibam o que você preencher.
|
||||
@plural {n} page|{n} pages {n} página|{n} páginas
|
||||
@plural {n} field|{n} fields {n} campo|{n} campos
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nome de campo colide com arquivos anteriores e será renomeado: {list}|{n} nomes de campos colidem com arquivos anteriores e serão renomeados: {list}
|
||||
Fit to width scrolling Ajustar à largura com rolagem
|
||||
Fit one full page Ajustar uma página inteira
|
||||
Default page display Exibição de página padrão
|
||||
@@ -2281,3 +2286,16 @@ smooth scrolling Off Desativada
|
||||
Tools panel side Lado do painel Ferramentas
|
||||
Add comment Adicionar comentário
|
||||
Change tag properties Alterar propriedades da tag
|
||||
Left out: {names} Deixados de fora: {names}
|
||||
already a PDF já é um PDF
|
||||
no unused output name nenhum nome de saída disponível
|
||||
no output folder was chosen nenhuma pasta de saída foi escolhida
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF baixado|{n} PDFs baixados
|
||||
Scan Digitalizar
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel.
|
||||
Choose photo… Escolher foto…
|
||||
No photo chosen Nenhuma foto escolhida
|
||||
Photo Foto
|
||||
Ink sensitivity Sensibilidade à tinta
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Nenhuma assinatura encontrada no quadro. Mova o quadro sobre a assinatura ou aumente a sensibilidade à tinta.
|
||||
Couldn't use the photo: {e} Não foi possível usar a foto: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -340,6 +340,7 @@
|
||||
Create from Создать из
|
||||
Single file Один файл
|
||||
Multiple files Несколько файлов
|
||||
Separate PDFs Отдельные PDF
|
||||
Images Изображения
|
||||
Clipboard Буфер обмена
|
||||
Blank page Пустая страница
|
||||
@@ -1870,6 +1871,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Изменить {key}
|
||||
Insert pages from {name} Вставить страницы из {name}
|
||||
Insert pages from {name} — 1 field renamed Вставить страницы из {name} — 1 поле переименовано
|
||||
Insert pages from {name} — fields renamed Вставить страницы из {name} — поля переименованы
|
||||
Fill in {name} Заполнить {name}
|
||||
Set the image of {name} Задать изображение: {name}
|
||||
Edit script of {name} Редактировать скрипт: {name}
|
||||
@@ -2042,7 +2045,9 @@
|
||||
|
||||
@plural {n} page|{n} pages {n} страница|{n} страницы|{n} страниц
|
||||
@plural {n} field|{n} fields {n} поле|{n} поля|{n} полей
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Имя {n} поля конфликтует с предыдущими файлами и будет переименовано: {list}|Названия {n} полей конфликтуют с предыдущими файлами и будут переименованы: {list}|Названия {n} полей конфликтуют с предыдущими файлами и будут переименованы: {list}
|
||||
Create PDF from multiple files… Создать PDF из нескольких файлов…
|
||||
Create separate PDFs… Создать отдельные PDF…
|
||||
Optimizing… removing user data Оптимизация… удаление пользовательских данных
|
||||
Optimizing… image {d} of {t} Оптимизация… изображение {d} из {t}
|
||||
Optimizing… cleaning up Оптимизация… очистка
|
||||
@@ -2180,3 +2185,16 @@ smooth scrolling Off Выкл
|
||||
Tools panel side Сторона панели инструментов
|
||||
Add comment Добавить комментарий
|
||||
Change tag properties Изменить свойства тега
|
||||
Left out: {names} Пропущены: {names}
|
||||
already a PDF уже PDF
|
||||
no unused output name нет свободного имени для выходного файла
|
||||
no output folder was chosen не выбрана папка для вывода
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Скачан {n} PDF-файл|Скачано {n} PDF-файла|Скачано {n} PDF-файлов
|
||||
Scan Сканировать
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу.
|
||||
Choose photo… Выбрать фото…
|
||||
No photo chosen Фото не выбрано
|
||||
Photo Фото
|
||||
Ink sensitivity Чувствительность к чернилам
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. В рамке не найдена подпись. Наведите рамку на подпись или увеличьте чувствительность к чернилам.
|
||||
Couldn't use the photo: {e} Не удалось использовать фото: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -485,6 +485,7 @@
|
||||
Create PDF from images Ustvari PDF iz slik
|
||||
Create PDF from images… Ustvari PDF iz slik…
|
||||
Create PDF from multiple files… Ustvari PDF iz več datotek…
|
||||
Create separate PDFs… Ustvari ločene PDF-je…
|
||||
Create a PDF Ustvari PDF
|
||||
Create a new Digital ID (self-signed, saved to a password-protected file) Ustvari novo digitalno identiteto (samopodpisano, shranjeno v datoteko, zaščiteno z geslom)
|
||||
Create accessibility report Ustvari poročilo o dostopnosti
|
||||
@@ -940,6 +941,8 @@
|
||||
Insert pages from a file… Vstavi strani iz datoteke…
|
||||
Insert pages from file… Vstavi strani iz datoteke…
|
||||
Insert pages from {name} Vstavi strani iz: {name}
|
||||
Insert pages from {name} — 1 field renamed Vstavi strani iz {name} — 1 polje preimenovano
|
||||
Insert pages from {name} — fields renamed Vstavi strani iz {name} — polja preimenovana
|
||||
Insert text Vstavi besedilo
|
||||
Inserted Vstavljeno
|
||||
Inserted text Vstavljeno besedilo
|
||||
@@ -1102,6 +1105,7 @@
|
||||
Multi-line Večvrstično
|
||||
Multiple Več
|
||||
Multiple files Več datotek
|
||||
Separate PDFs Ločeni PDF-ji
|
||||
Multiple selection Večkratna izbira
|
||||
Name Ime
|
||||
Name (A–Z) Ime (A–Ž)
|
||||
@@ -2272,3 +2276,17 @@ smooth scrolling Off Izklopljeno
|
||||
Tools panel side Stran podokna z orodji
|
||||
Add comment Dodaj komentar
|
||||
Change tag properties Spremeni lastnosti oznake
|
||||
Left out: {names} Izpuščeno: {names}
|
||||
already a PDF je že PDF
|
||||
no unused output name ni prostega izhodnega imena
|
||||
no output folder was chosen izhodna mapa ni bila izbrana
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Prenesen {n} PDF|Prenesena {n} PDF-ja|Preneseni {n} PDF-ji|Prenesenih {n} PDF-jev
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list}
|
||||
Scan Skeniraj
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir.
|
||||
Choose photo… Izberi fotografijo…
|
||||
No photo chosen Fotografija ni izbrana
|
||||
Photo Fotografija
|
||||
Ink sensitivity Občutljivost za črnilo
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. V okvirju ni bilo najdenega podpisa. Premaknite okvir nad podpis ali povečajte občutljivost za črnilo.
|
||||
Couldn't use the photo: {e} Fotografije ni bilo mogoče uporabiti: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -31,6 +31,7 @@ comment menu Edit సవరించు
|
||||
New blank PDF కొత్త ఖాళీ PDF
|
||||
Create PDF from file… ఫైల్ నుండి PDF సృష్టించు…
|
||||
Create PDF from multiple files… అనేక ఫైల్ల నుండి PDF సృష్టించు…
|
||||
Create separate PDFs… వేర్వేరు PDFలను సృష్టించు…
|
||||
Create PDF from images… చిత్రాల నుండి PDF సృష్టించు…
|
||||
Create PDF from clipboard క్లిప్బోర్డ్ నుండి PDF సృష్టించు
|
||||
Combine files… ఫైల్లను కలుపు…
|
||||
@@ -323,6 +324,7 @@ comment menu Edit సవరించు
|
||||
Create from సృష్టించే మూలం
|
||||
Single file ఒకే ఫైల్
|
||||
Multiple files అనేక ఫైల్లు
|
||||
Separate PDFs వేర్వేరు PDFలు
|
||||
Images చిత్రాలు
|
||||
Clipboard క్లిప్బోర్డ్
|
||||
Blank page ఖాళీ పేజీ
|
||||
@@ -1789,6 +1791,8 @@ action wizard Start ప్రారంభించు
|
||||
{month} {y} {month} {y}
|
||||
Change {key} {key} మార్చు
|
||||
Insert pages from {name} {name} నుండి పేజీలను చొప్పించు
|
||||
Insert pages from {name} — 1 field renamed {name} నుండి పేజీలను చొప్పించు — 1 ఫీల్డ్ పేరు మార్చబడింది
|
||||
Insert pages from {name} — fields renamed {name} నుండి పేజీలను చొప్పించు — ఫీల్డ్ల పేర్లు మార్చబడ్డాయి
|
||||
Fill in {name} {name}ను నింపు
|
||||
Set the image of {name} {name} చిత్రాన్ని సెట్ చేయి
|
||||
Edit script of {name} {name} స్క్రిప్ట్ను సవరించు
|
||||
@@ -1944,6 +1948,7 @@ action wizard Start ప్రారంభించు
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA ఫారం: దీని ఫీల్డ్లు, XFA డేటా ఎప్పుడూ ఒకేలా ఉంచబడతాయి, కాబట్టి మీరు నింపినది ఇతర వ్యూయర్లలో కూడా కనిపిస్తుంది.
|
||||
@plural {n} page|{n} pages {n} పేజీ|{n} పేజీలు
|
||||
@plural {n} field|{n} fields {n} ఫీల్డ్|{n} ఫీల్డ్లు
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ఫీల్డ్ పేరు మునుపటి ఫైళ్లతో సరిపోలుతుంది, పేరు మార్చబడుతుంది: {list}|{n} ఫీల్డ్ పేర్లు మునుపటి ఫైళ్లతో సరిపోలుతున్నాయి, పేర్లు మార్చబడతాయి: {list}
|
||||
Switch to two-page view first to show the cover page కవర్ పేజీని చూపించడానికి ముందుగా రెండు పేజీల వీక్షణకు మారండి
|
||||
Page display: {layout} పేజీ ప్రదర్శన: {layout}
|
||||
Fit to width scrolling వెడల్పుకు సరిపోయేలా స్క్రోలింగ్
|
||||
@@ -2180,3 +2185,16 @@ smooth scrolling Off ఆఫ్
|
||||
Tools panel side టూల్స్ ప్యానెల్ వైపు
|
||||
Add comment వ్యాఖ్యను జోడించు
|
||||
Change tag properties ట్యాగ్ లక్షణాలను మార్చు
|
||||
Left out: {names} వదిలివేసినవి: {names}
|
||||
already a PDF ఇప్పటికే PDF
|
||||
no unused output name ఉపయోగించని అవుట్పుట్ పేరు లేదు
|
||||
no output folder was chosen అవుట్పుట్ ఫోల్డర్ ఎంచుకోలేదు
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF డౌన్లోడ్ చేయబడింది|{n} PDFలు డౌన్లోడ్ చేయబడ్డాయి
|
||||
Scan స్కాన్
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది.
|
||||
Choose photo… ఫోటో ఎంచుకోండి…
|
||||
No photo chosen ఫోటో ఎంచుకోలేదు
|
||||
Photo ఫోటో
|
||||
Ink sensitivity ఇంక్ సున్నితత్వం
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. ఫ్రేమ్లో సంతకం కనబడలేదు. ఫ్రేమ్ను సంతకం మీదకు జరపండి లేదా ఇంక్ సున్నితత్వాన్ని పెంచండి.
|
||||
Couldn't use the photo: {e} ఫోటోను ఉపయోగించలేకపోయాం: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -20,6 +20,7 @@
|
||||
Create PDF from file… Dosyadan PDF oluştur…
|
||||
Create PDF from images… Görsellerden PDF oluştur…
|
||||
Create PDF from multiple files… Birden çok dosyadan PDF oluştur…
|
||||
Create separate PDFs… Ayrı PDF'ler oluştur…
|
||||
Create PDF from clipboard Panodan PDF oluştur
|
||||
Combine files… Dosyaları birleştir…
|
||||
Save Kaydet
|
||||
@@ -304,6 +305,7 @@
|
||||
Create from Şuradan oluştur
|
||||
Single file Tek dosya
|
||||
Multiple files Birden çok dosya
|
||||
Separate PDFs Ayrı PDF'ler
|
||||
Images Resimler
|
||||
Clipboard Pano
|
||||
Blank page Boş sayfa
|
||||
@@ -1766,6 +1768,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} {key} değiştir
|
||||
Insert pages from {name} {name} belgesinden sayfalar ekle
|
||||
Insert pages from {name} — 1 field renamed {name} dosyasından sayfa ekle — 1 alan yeniden adlandırıldı
|
||||
Insert pages from {name} — fields renamed {name} dosyasından sayfa ekle — alanlar yeniden adlandırıldı
|
||||
Fill in {name} {name} doldur
|
||||
Set the image of {name} {name} resmini ayarla
|
||||
Edit script of {name} {name} komut dosyasını düzenle
|
||||
@@ -2263,3 +2267,17 @@ smooth scrolling Off Kapalı
|
||||
Tools panel side Araçlar panelinin yeri
|
||||
Add comment Yorum ekle
|
||||
Change tag properties Etiket özelliklerini değiştir
|
||||
Left out: {names} Dışarıda bırakılanlar: {names}
|
||||
already a PDF zaten bir PDF
|
||||
no unused output name kullanılmayan çıktı adı yok
|
||||
no output folder was chosen çıktı klasörü seçilmedi
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF indirildi
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list}
|
||||
Scan Tara
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır.
|
||||
Choose photo… Fotoğraf seç…
|
||||
No photo chosen Fotoğraf seçilmedi
|
||||
Photo Fotoğraf
|
||||
Ink sensitivity Mürekkep hassasiyeti
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Çerçevede imza bulunamadı. Çerçeveyi imzanın üzerine taşıyın veya mürekkep hassasiyetini artırın.
|
||||
Couldn't use the photo: {e} Fotoğraf kullanılamadı: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 818 and column 12.
|
@@ -345,6 +345,7 @@
|
||||
Create from Створити з
|
||||
Single file Один файл
|
||||
Multiple files Кілька файлів
|
||||
Separate PDFs Окремі PDF-файли
|
||||
Images Зображення
|
||||
Clipboard Буфер обміну
|
||||
Blank page Порожня сторінка
|
||||
@@ -1867,6 +1868,8 @@
|
||||
{month} {y} {month} {y}
|
||||
Change {key} Змінити: {key}
|
||||
Insert pages from {name} Вставити сторінки з {name}
|
||||
Insert pages from {name} — 1 field renamed Вставити сторінки з {name} — 1 поле перейменовано
|
||||
Insert pages from {name} — fields renamed Вставити сторінки з {name} — поля перейменовано
|
||||
Fill in {name} Заповнити {name}
|
||||
Set the image of {name} Задати зображення для {name}
|
||||
Edit script of {name} Редагувати сценарій {name}
|
||||
@@ -2033,7 +2036,9 @@
|
||||
Couldn't import the signature image: {e} Не вдалося імпортувати зображення підпису: {e}
|
||||
@plural {n} page|{n} pages {n} сторінка|{n} сторінки|{n} сторінок
|
||||
@plural {n} field|{n} fields {n} поле|{n} поля|{n} полів
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Назва {n} поля збігається з попередніми файлами та буде перейменована: {list}|Назви {n} полів збігаються з попередніми файлами та будуть перейменовані: {list}|Назви {n} полів збігаються з попередніми файлами та будуть перейменовані: {list}
|
||||
Create PDF from multiple files… Створити PDF із кількох файлів…
|
||||
Create separate PDFs… Створити окремі PDF-файли…
|
||||
Optimizing… removing user data Оптимізація… видалення даних користувача
|
||||
Optimizing… image {d} of {t} Оптимізація… зображення {d} із {t}
|
||||
Optimizing… cleaning up Оптимізація… очищення
|
||||
@@ -2289,3 +2294,16 @@ smooth scrolling Off Вимкнено
|
||||
Tools panel side Бік панелі інструментів
|
||||
Add comment Додати коментар
|
||||
Change tag properties Змінити властивості тегу
|
||||
Left out: {names} Пропущено: {names}
|
||||
already a PDF вже PDF
|
||||
no unused output name немає вільної назви для вихідного файлу
|
||||
no output folder was chosen не вибрано теку для виводу
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs Завантажено {n} PDF-файл|Завантажено {n} PDF-файли|Завантажено {n} PDF-файлів
|
||||
Scan Сканувати
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір.
|
||||
Choose photo… Вибрати фото…
|
||||
No photo chosen Фото не вибрано
|
||||
Photo Фото
|
||||
Ink sensitivity Чутливість до чорнила
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. У рамці не знайдено підпису. Наведіть рамку на підпис або збільште чутливість до чорнила.
|
||||
Couldn't use the photo: {e} Не вдалося використати фото: {e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -94,6 +94,7 @@
|
||||
Create PDF from file… 从文件创建 PDF…
|
||||
Create PDF from images… 从图像创建 PDF…
|
||||
Create PDF from multiple files… 从多个文件创建 PDF…
|
||||
Create separate PDFs… 创建单独的 PDF…
|
||||
Create PDF from clipboard 从剪贴板创建 PDF
|
||||
Combine files… 合并文件…
|
||||
Save 保存
|
||||
@@ -958,6 +959,7 @@
|
||||
Bring your own model (off by default) 自备模型(默认关闭)
|
||||
Single file 单个文件
|
||||
Multiple files 多个文件
|
||||
Separate PDFs 单独的 PDF
|
||||
Images 图像
|
||||
Clipboard 剪贴板
|
||||
Blank page 空白页
|
||||
@@ -1970,6 +1972,8 @@
|
||||
Images and text (converted to PDF) 图像和文本(转换为 PDF)
|
||||
Import {name} 导入 {name}
|
||||
Insert pages from {name} 从 {name} 插入页面
|
||||
Insert pages from {name} — 1 field renamed 从 {name} 插入页面 — 已重命名 1 个字段
|
||||
Insert pages from {name} — fields renamed 从 {name} 插入页面 — 已重命名字段
|
||||
Items marked Ready work today. 标记为就绪的项目现已可用。
|
||||
JavaScript JavaScript
|
||||
JavaScript, e.g. getField("total").value JavaScript,例如 getField("total").value
|
||||
@@ -2097,6 +2101,7 @@
|
||||
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA 表单:字段与 XFA 数据保持同步,以便其他阅读器显示您填写的内容。
|
||||
@plural {n} page|{n} pages {n} 页
|
||||
@plural {n} field|{n} fields {n} 个字段
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 个字段名与之前的文件冲突,将被重命名: {list}
|
||||
Created a PDF from {n} files 已从 {n} 个文件创建 PDF
|
||||
PDF, images and text PDF、图像和文本
|
||||
Insert a file before page {label} 在第 {label} 页之前插入文件
|
||||
@@ -2231,3 +2236,16 @@ smooth scrolling Off 关
|
||||
Tools panel side 工具面板位置
|
||||
Add comment 添加注释
|
||||
Change tag properties 更改标签属性
|
||||
Left out: {names} 已略过:{names}
|
||||
already a PDF 已经是 PDF
|
||||
no unused output name 没有可用的输出文件名
|
||||
no output folder was chosen 未选择输出文件夹
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下载 {n} 个 PDF
|
||||
Scan 扫描
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。
|
||||
Choose photo… 选择照片…
|
||||
No photo chosen 未选择照片
|
||||
Photo 照片
|
||||
Ink sensitivity 墨迹灵敏度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框内未找到签名。请将框移到签名上,或提高墨迹灵敏度。
|
||||
Couldn't use the photo: {e} 无法使用该照片:{e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -53,6 +53,7 @@
|
||||
Create PDF from file… 從檔案建立 PDF…
|
||||
Create PDF from images… 從圖片建立 PDF…
|
||||
Create PDF from multiple files… 從多個檔案建立 PDF…
|
||||
Create separate PDFs… 建立個別 PDF…
|
||||
Create PDF from clipboard 從剪貼簿建立 PDF
|
||||
Combine files… 合併檔案…
|
||||
Save 儲存
|
||||
@@ -903,6 +904,7 @@
|
||||
Bring your own model (off by default) 使用自己的模型(預設關閉)
|
||||
Single file 單一檔案
|
||||
Multiple files 多個檔案
|
||||
Separate PDFs 個別 PDF
|
||||
Images 圖片
|
||||
Clipboard 剪貼簿
|
||||
Blank page 空白頁面
|
||||
@@ -1868,6 +1870,8 @@
|
||||
{month} {y} {y} 年 {month}
|
||||
Change {key} 變更{key}
|
||||
Insert pages from {name} 從 {name} 插入頁面
|
||||
Insert pages from {name} — 1 field renamed 從 {name} 插入頁面 — 已重新命名 1 個欄位
|
||||
Insert pages from {name} — fields renamed 從 {name} 插入頁面 — 已重新命名欄位
|
||||
Fill in {name} 填寫 {name}
|
||||
Set the image of {name} 設定 {name} 的圖片
|
||||
Edit script of {name} 編輯 {name} 的指令碼
|
||||
@@ -2071,6 +2075,7 @@
|
||||
|
||||
# Multi-object selection and movement (#844)
|
||||
@plural {n} object selected|{n} objects selected 已選取 {n} 個物件
|
||||
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 個欄位名稱與先前的檔案衝突,將被重新命名: {list}
|
||||
Drag the selection to move it. Esc clears the selection. 拖曳選取範圍即可移動。按 Esc 清除選取。
|
||||
Shift-click to select several objects. Drag empty space to select an area. 按住 Shift 點選可選取多個物件。拖曳空白區域可框選。
|
||||
Select at most 1000 objects at a time. 一次最多選取 1000 個物件。
|
||||
@@ -2141,3 +2146,16 @@ smooth scrolling Off 關
|
||||
Tools panel side 工具面板位置
|
||||
Add comment 新增註解
|
||||
Change tag properties 變更標籤屬性
|
||||
Left out: {names} 已略過:{names}
|
||||
already a PDF 已經是 PDF
|
||||
no unused output name 沒有可用的輸出檔名
|
||||
no output folder was chosen 未選擇輸出資料夾
|
||||
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下載 {n} 個 PDF
|
||||
Scan 掃描
|
||||
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。
|
||||
Choose photo… 選擇相片…
|
||||
No photo chosen 未選擇相片
|
||||
Photo 相片
|
||||
Ink sensitivity 墨跡靈敏度
|
||||
No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. 框內找不到簽名。請將框移到簽名上,或提高墨跡靈敏度。
|
||||
Couldn't use the photo: {e} 無法使用此相片:{e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
+40
-15
@@ -6,10 +6,10 @@ use std::sync::OnceLock;
|
||||
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
|
||||
use craft_ui::icons::{SvgIconSet, SvgStyle};
|
||||
|
||||
use egui::{Color32, Rect, Response, Vec2};
|
||||
use egui::{Color32, Rect, Response, Stroke, Vec2};
|
||||
|
||||
use crate::icon_data::ICONS;
|
||||
use crate::theme::Tokens;
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
fn white_icons() -> &'static SvgIconSet {
|
||||
static MAP: OnceLock<SvgIconSet> = OnceLock::new();
|
||||
@@ -36,18 +36,42 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) {
|
||||
/// Square icon button: transparent until hovered; `selected` gets the accent treatment.
|
||||
pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The shared button paints no fill of its own: the eased press wash below replaces it.
|
||||
let clear = |icon| ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon };
|
||||
let style = IconButtonStyle {
|
||||
normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon },
|
||||
hovered: ButtonVisuals { fill: t.hover, stroke: egui::Stroke::NONE, icon: t.icon },
|
||||
pressed: Some(ButtonVisuals { fill: t.pressed, stroke: egui::Stroke::NONE, icon: t.icon }),
|
||||
selected: ButtonVisuals { fill: t.accent_soft, stroke: egui::Stroke::NONE, icon: t.accent_text },
|
||||
normal: clear(t.icon),
|
||||
hovered: clear(t.icon),
|
||||
pressed: Some(clear(t.icon)),
|
||||
selected: clear(t.accent_text),
|
||||
corner_radius: t.radius.into(),
|
||||
focus_stroke: egui::Stroke::new(1.0, t.accent),
|
||||
focus_stroke: Stroke::new(1.0, t.accent),
|
||||
};
|
||||
let label = if tooltip.is_empty() { name } else { tooltip };
|
||||
let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| {
|
||||
paint(ui, rect, name, (box_size * 0.5).round(), tint);
|
||||
});
|
||||
// The wash eases from the response, so reserve its place under the icon and focus ring.
|
||||
let wash = ui.painter().add(egui::Shape::Noop);
|
||||
let mut painted = None;
|
||||
let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |_, rect, tint| painted = Some((rect, tint)));
|
||||
if let Some((rect, tint)) = painted {
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
let mut shapes = Vec::with_capacity(2);
|
||||
let fill = press.wash_color(&t, selected);
|
||||
if fill.a() > 0 {
|
||||
shapes.push(egui::Shape::rect_filled(rect, t.radius, fill));
|
||||
}
|
||||
if selected {
|
||||
shapes.push(egui::Shape::rect_stroke(
|
||||
rect.shrink(0.5),
|
||||
t.radius,
|
||||
Stroke::new(1.0, t.accent.gamma_multiply(0.45)),
|
||||
egui::StrokeKind::Inside,
|
||||
));
|
||||
}
|
||||
ui.painter().set(wash, egui::Shape::Vec(shapes));
|
||||
// The glyph shrinks a little and drops a pixel while the pointer is down.
|
||||
let size = (box_size * 0.5).round() * (1.0 - 0.08 * press.down);
|
||||
paint(ui, rect.translate(press.offset()), name, size, tint);
|
||||
}
|
||||
let resp = theme::hand(resp);
|
||||
if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) }
|
||||
}
|
||||
|
||||
@@ -92,9 +116,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_button_preserves_theme_painting_and_pointer_responses() {
|
||||
// Freeze the original paint path across both themes. The added keyboard-focus
|
||||
// outline is covered independently in craft-ui.
|
||||
fn shared_button_preserves_pointer_responses() {
|
||||
// The shared button must respond like the original allocate-and-paint path in both
|
||||
// themes. Its painting differs on purpose (the eased press wash, the selected outline
|
||||
// and the sinking glyph); the keyboard-focus outline is covered in craft-ui.
|
||||
fn frame(
|
||||
ctx: &egui::Context,
|
||||
events: Vec<egui::Event>,
|
||||
@@ -151,7 +176,7 @@ mod tests {
|
||||
5 => vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))],
|
||||
_ => vec![],
|
||||
};
|
||||
let (a, before) = frame(&old, events.clone(), selected, enabled, true);
|
||||
let (a, _) = frame(&old, events.clone(), selected, enabled, true);
|
||||
let (b, after) = frame(&new, events, selected, enabled, false);
|
||||
center = a.rect.center();
|
||||
assert_eq!(a.rect, b.rect);
|
||||
@@ -159,7 +184,7 @@ mod tests {
|
||||
(a.clicked(), a.hovered(), a.is_pointer_button_down_on()),
|
||||
(b.clicked(), b.hovered(), b.is_pointer_button_down_on())
|
||||
);
|
||||
assert_eq!(before, after, "{theme:?}, selected={selected}, enabled={enabled}, step={step}");
|
||||
assert!(!after.is_empty(), "{theme:?}, selected={selected}, enabled={enabled}, step={step}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,8 @@ pub struct DuplicateDraft {
|
||||
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageClip {
|
||||
/// The document the pages were copied from: pasting back into it keeps field names.
|
||||
pub from: DocId,
|
||||
pub name: String,
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
pub pages: Vec<usize>,
|
||||
@@ -593,6 +595,8 @@ pub struct PdfCraftApp {
|
||||
pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>,
|
||||
/// Combine files: the files staged so far.
|
||||
pub combine_draft: Vec<combine_ui::CombineFile>,
|
||||
/// The combine list's field-name conflict preview (computed once per list, never saved).
|
||||
pub combine_conflicts: combine_ui::ConflictPreview,
|
||||
/// The Combine files tab: whether it is open, shown, its selection and undo history.
|
||||
pub combine_tab: combine_ui::CombineTab,
|
||||
/// The Combine files table's column order and widths (kept in the settings).
|
||||
@@ -673,6 +677,8 @@ pub struct PdfCraftApp {
|
||||
/// The Create signature / initials dialog, and its typed preview.
|
||||
pub signature_draft: fill_sign::SigDraft,
|
||||
pub(crate) signature_preview: Option<(fill_sign::SavedSig, egui::TextureHandle)>,
|
||||
/// The Scan tab's photo, keyed by its version.
|
||||
pub(crate) signature_photo: Option<(u64, egui::TextureHandle)>,
|
||||
pub(crate) saved_signature_previews: [Option<(fill_sign::SavedSig, egui::TextureHandle)>; 2],
|
||||
/// What visible digital signatures show on their left, and its preview in Sign as.
|
||||
pub sign_graphic: sign_ui::SignGraphic,
|
||||
@@ -865,6 +871,7 @@ impl PdfCraftApp {
|
||||
cert_viewer: None,
|
||||
space_audit: Vec::new(),
|
||||
combine_draft: Vec::new(),
|
||||
combine_conflicts: Default::default(),
|
||||
combine_tab: Default::default(),
|
||||
combine_columns: Default::default(),
|
||||
combine_view: Default::default(),
|
||||
@@ -909,6 +916,7 @@ impl PdfCraftApp {
|
||||
initials: None,
|
||||
signature_draft: Default::default(),
|
||||
signature_preview: None,
|
||||
signature_photo: None,
|
||||
saved_signature_previews: [None, None],
|
||||
sign_graphic: sign_ui::SignGraphic::Name,
|
||||
sign_graphic_preview: None,
|
||||
@@ -2290,6 +2298,8 @@ impl eframe::App for PdfCraftApp {
|
||||
self.process_signature_images();
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let dropped = self.drop_on_signature_pad(dropped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let dropped = self.drop_on_grid(dropped, ctx);
|
||||
for f in dropped {
|
||||
// Files dropped on the Combine files tab join its list instead of opening.
|
||||
|
||||
@@ -154,10 +154,10 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.separator();
|
||||
for (_, h) in &hits {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(8.0, 9.0), vec2(18.0, 18.0)), h.icon, 17.0, t.icon);
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 9.0), vec2(18.0, 18.0)), h.icon, 17.0, t.icon);
|
||||
// Display is translated; matching above already considered both languages.
|
||||
let shown = crate::i18n::command_label(&h.label);
|
||||
let shown_detail = tl!(&h.detail).to_string();
|
||||
@@ -167,16 +167,16 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let detail_max = (rect.width() * 0.42).clamp(0.0, 220.0);
|
||||
let detail = crate::widgets::fit_line(&shown_detail, detail_max, |s| crate::widgets::text_width(ui, s, &detail_font));
|
||||
let detail_w = crate::widgets::text_width(ui, &detail, &detail_font);
|
||||
ui.painter().text(rect.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, detail, detail_font, t.text_faint);
|
||||
ui.painter().text(body.right_center() - vec2(10.0, 0.0), Align2::RIGHT_CENTER, detail, detail_font, t.text_faint);
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
rect.left_center() + vec2(36.0, 0.0),
|
||||
body.left_center() + vec2(36.0, 0.0),
|
||||
&shown,
|
||||
theme::regular(13.5),
|
||||
fg,
|
||||
(rect.width() - 36.0 - detail_w - 24.0).max(0.0),
|
||||
);
|
||||
if resp.clicked() {
|
||||
if theme::hand(resp).clicked() {
|
||||
chosen = Some((h.command, h.group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
(go_back, close)
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.selectable(false)
|
||||
.sense(Sense::click()),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
app.all_tools_expanded = !app.all_tools_expanded;
|
||||
@@ -89,10 +92,10 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32,
|
||||
fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g));
|
||||
let right_reserved = match (g.badge, g.availability) {
|
||||
(Some(b), _) => ui.fonts_mut(|f| f.layout_no_wrap(tl!(b).to_owned(), theme::semibold(9.5), Color32::WHITE).size().x) + 26.0,
|
||||
(None, Availability::Planned(m)) if resp.hovered() => {
|
||||
@@ -102,20 +105,20 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
_ => 8.0,
|
||||
};
|
||||
let clipped =
|
||||
row_label(ui, tl!(g.label), theme::regular(13.5), t.text, rect.left_center() + vec2(36.0, 0.0), rect.width() - 36.0 - right_reserved);
|
||||
row_label(ui, tl!(g.label), theme::regular(13.5), t.text, body.left_center() + vec2(36.0, 0.0), rect.width() - 36.0 - right_reserved);
|
||||
match (g.badge, g.availability) {
|
||||
(Some(b), _) => {
|
||||
let font = theme::semibold(9.5);
|
||||
let badge = tl!(b);
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(badge.to_string(), font.clone(), Color32::WHITE).size().x);
|
||||
let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
|
||||
let r = Rect::from_center_size(body.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, badge, font, Color32::WHITE);
|
||||
}
|
||||
// Planned tools: a quiet milestone hint instead of a chip, so the list stays calm.
|
||||
(None, Availability::Planned(m)) if resp.hovered() => {
|
||||
ui.painter().text(
|
||||
rect.right_center() - vec2(10.0, 0.0),
|
||||
body.right_center() - vec2(10.0, 0.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("{} · {m}", tl!("Planned")),
|
||||
theme::medium(10.5),
|
||||
@@ -130,7 +133,7 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
Availability::Provider => tl!("Optional: needs an AI provider you configure").into(),
|
||||
};
|
||||
let tip = if clipped { format!("{} — {tip}", tl!(g.label)) } else { tip };
|
||||
resp.on_hover_text(tip)
|
||||
theme::hand(resp).on_hover_text(tip)
|
||||
}
|
||||
|
||||
fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
|
||||
@@ -204,13 +207,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(item.label)));
|
||||
let ready = item.availability == Availability::Ready;
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
let fg = if ready { t.text } else { t.text_muted };
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(rect.min + vec2(6.0, 8.0), vec2(18.0, 18.0)),
|
||||
Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)),
|
||||
item.icon,
|
||||
17.0,
|
||||
if ready { hue(g) } else { t.text_faint },
|
||||
@@ -223,11 +226,11 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
|
||||
let font = theme::semibold(9.5);
|
||||
let chip_label = tl!(chip).to_string();
|
||||
let w = crate::widgets::text_width(ui, &chip_label, &font);
|
||||
let r = Rect::from_center_size(rect.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
|
||||
row_label(ui, tl!(item.label), theme::regular(13.0), fg, rect.left_center() + vec2(34.0, 0.0), r.left() - rect.left() - 42.0);
|
||||
let r = Rect::from_center_size(body.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0));
|
||||
row_label(ui, tl!(item.label), theme::regular(13.0), fg, body.left_center() + vec2(34.0, 0.0), r.left() - body.left() - 42.0);
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), fill);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg);
|
||||
if resp.on_hover_text(format!("{} — {}", tl!(item.label), item.command)).clicked() {
|
||||
if theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)).clicked() {
|
||||
run = Some(item.command);
|
||||
}
|
||||
}
|
||||
@@ -283,14 +286,12 @@ fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let active = app.quick_tool == crate::QuickTool::Stamp(kind);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, kind.label()));
|
||||
if active {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, active);
|
||||
let body = rect.translate(press.offset());
|
||||
let [r, g, b] = kind.color().map(|v| (v * 255.0) as u8);
|
||||
let col = Color32::from_rgb(r, g, b);
|
||||
let chip = Rect::from_min_size(rect.min + vec2(8.0, 5.0), vec2((rect.width() - 16.0).min(200.0), 28.0));
|
||||
let chip = Rect::from_min_size(body.min + vec2(8.0, 5.0), vec2((rect.width() - 16.0).min(200.0), 28.0));
|
||||
if group == StampGroup::SignHere {
|
||||
let tip = chip.height() * 0.45;
|
||||
let pts = vec![
|
||||
@@ -330,7 +331,7 @@ fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
if theme::hand(resp).clicked() {
|
||||
app.quick_tool = crate::QuickTool::Stamp(kind);
|
||||
}
|
||||
}
|
||||
@@ -636,12 +637,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
for (li, l) in info.layers.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, l.visible, &l.name));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(rect.min + vec2(4.0, 7.0), vec2(18.0, 18.0)),
|
||||
Rect::from_min_size(body.min + vec2(4.0, 7.0), vec2(18.0, 18.0)),
|
||||
if l.visible { "eye" } else { "eye-off" },
|
||||
16.0,
|
||||
t.icon,
|
||||
@@ -649,13 +650,13 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let fg = if l.visible { t.text } else { t.text_faint };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
rect.left_center() + vec2(32.0, 0.0),
|
||||
body.left_center() + vec2(32.0, 0.0),
|
||||
&l.name,
|
||||
theme::regular(13.0),
|
||||
fg,
|
||||
(rect.width() - 40.0).max(0.0),
|
||||
);
|
||||
if resp.on_hover_text(if l.visible { tl!("Hide layer") } else { tl!("Show layer") }).clicked() {
|
||||
if theme::hand(resp).on_hover_text(if l.visible { tl!("Hide layer") } else { tl!("Show layer") }).clicked() {
|
||||
toggle_layer = Some((li, !l.visible));
|
||||
}
|
||||
}
|
||||
@@ -929,9 +930,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
let h = (galley.size().y + 12.0).max(28.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
theme::Press::track(ui, &resp).wash(ui, rect, 6, false);
|
||||
let x0 = rect.left() + indent;
|
||||
if !item.children.is_empty() {
|
||||
let tri = Rect::from_min_size(pos2(x0, rect.top() + 6.0), vec2(16.0, 16.0));
|
||||
@@ -948,7 +947,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
let pos = pos2(rect.right() - 6.0 - g.size().x, rect.top() + 14.0 - g.size().y / 2.0);
|
||||
ui.painter().galley(pos, g, t.text_faint);
|
||||
}
|
||||
if resp.clicked()
|
||||
if theme::hand(resp.clone()).clicked()
|
||||
&& let Some(p) = item.page
|
||||
{
|
||||
*cx.nav = Some(Nav::Dest(p, item.view));
|
||||
@@ -1040,15 +1039,12 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, picked, info.clone()));
|
||||
// The current page keeps its heavier border; picked pages share its fill.
|
||||
let selected = i == view.current;
|
||||
if selected || picked {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(8), t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
|
||||
}
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 8, selected || picked);
|
||||
if picked {
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
let pr = rect.shrink(8.0);
|
||||
let pr = rect.translate(press.offset()).shrink(8.0);
|
||||
ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE);
|
||||
if let Some(tex) = view.thumb(i) {
|
||||
ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
@@ -1060,6 +1056,7 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted }));
|
||||
let resp = theme::hand(resp);
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if m.shift || m.command {
|
||||
@@ -1252,20 +1249,20 @@ fn fields(
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name));
|
||||
if selected || resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), if selected { t.pressed } else { t.hover });
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, selected);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, Color32::from_rgb(0x8E, 0x4E, 0xE6));
|
||||
let value =
|
||||
f.value.as_ref().map(|v| if v.chars().count() > 18 { format!("{}…", v.chars().take(17).collect::<String>()) } else { v.clone() });
|
||||
let value_font = theme::regular(11.5);
|
||||
let value_w = value.as_ref().map(|v| crate::widgets::text_width(ui, v, &value_font)).unwrap_or(0.0);
|
||||
let arrows = if preparing && f.page.is_some() { 56.0 } else { 0.0 };
|
||||
let max_w = (rect.width() - 32.0 - value_w - arrows - 16.0).max(0.0);
|
||||
crate::widgets::paint_left(ui, rect.left_center() + vec2(32.0, 0.0), &f.name, theme::regular(13.0), t.text, max_w);
|
||||
crate::widgets::paint_left(ui, body.left_center() + vec2(32.0, 0.0), &f.name, theme::regular(13.0), t.text, max_w);
|
||||
if let Some(v) = value {
|
||||
let x = if arrows > 0.0 { 64.0 } else { 8.0 };
|
||||
ui.painter().text(rect.right_center() - vec2(x, 0.0), Align2::RIGHT_CENTER, v, value_font, t.text_faint);
|
||||
ui.painter().text(body.right_center() - vec2(x, 0.0), Align2::RIGHT_CENTER, v, value_font, t.text_faint);
|
||||
}
|
||||
let kind = format!("{:?}", f.kind);
|
||||
let mut tip = crate::i18n::fmt(tl!("{kind} field"), &[("kind", tl!(&kind))]);
|
||||
@@ -1283,11 +1280,10 @@ fn fields(
|
||||
{
|
||||
let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click());
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name)));
|
||||
if b.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
icons::paint(ui, r.shrink(3.0), icon, 15.0, t.icon);
|
||||
if b.on_hover_text(tip).clicked() {
|
||||
let bpress = theme::Press::track(ui, &b);
|
||||
bpress.wash(ui, r, 4, false);
|
||||
icons::paint(ui, r.shrink(3.0).translate(bpress.offset()), icon, 15.0, t.icon);
|
||||
if theme::hand(b).on_hover_text(tip).clicked() {
|
||||
*edit = Some(pdfcraft_engine::Edit::MoveInTabOrder { name: f.name.clone(), earlier });
|
||||
}
|
||||
}
|
||||
@@ -1305,7 +1301,7 @@ fn fields(
|
||||
{
|
||||
selection.select(key, ui.input(|i| i.modifiers.shift || i.modifiers.command));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked()
|
||||
if theme::hand(resp).on_hover_text(tip).clicked()
|
||||
&& let (Some(p), Some(r)) = (f.page, f.rect)
|
||||
{
|
||||
*nav = Some(Nav::Flash(p, r));
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! The embedded faces (Inter, egui's defaults, craft-fonts) come first in every family; this one
|
||||
//! only draws characters none of them has, such as an Arabic file name in a build without
|
||||
//! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). It is
|
||||
//! craft-fonts, or the Simplified Chinese interface in a build without a `Hans` face (#826). A
|
||||
//! second installed face, [`devanagari_fallback`], draws Hindi when one is installed. It is
|
||||
//! read at runtime and never embedded or shipped (AGENTS.md §1.4), and `PDFCRAFT_SYSTEM_FONTS=0`
|
||||
//! turns it off (published screenshots do).
|
||||
|
||||
@@ -17,8 +18,10 @@ const MAX_BYTES: u64 = 32 << 20;
|
||||
const MAX_HAN_BYTES: u64 = 96 << 20;
|
||||
/// Faces tried in a collection (`.ttc`).
|
||||
const MAX_FACES: u32 = 16;
|
||||
/// Arabic letter alef: the face must have it to be worth loading.
|
||||
/// Arabic letter alef: the face must have it to be worth loading for Arabic script.
|
||||
const PROBE: char = '\u{0627}';
|
||||
/// Devanagari letter ka: the face must have it to cover Hindi.
|
||||
const DEVANAGARI_PROBE: char = '\u{0915}';
|
||||
/// 欢 (U+6B22): Simplified only, absent from the Japanese craft-fonts faces.
|
||||
const HANS_PROBE: char = '\u{6B22}';
|
||||
/// 說 (U+8AAA): Traditional only, absent from the Japanese craft-fonts faces.
|
||||
@@ -62,6 +65,55 @@ fn load(need: Need) -> Option<Arc<FontData>> {
|
||||
candidates(need).iter().find_map(|path| read(path, probe, max))
|
||||
}
|
||||
|
||||
/// The installed fallback face for Devanagari (Hindi), read once. `None` when it is turned off
|
||||
/// or no candidate is installed; the interface then draws what the embedded faces can.
|
||||
pub fn devanagari_fallback() -> Option<Arc<FontData>> {
|
||||
static CACHE: OnceLock<Option<Arc<FontData>>> = OnceLock::new();
|
||||
CACHE
|
||||
.get_or_init(|| {
|
||||
if std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
|
||||
return None;
|
||||
}
|
||||
devanagari_candidates().iter().find_map(|path| read(path, DEVANAGARI_PROBE, MAX_BYTES))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Well-known locations of Devanagari (Hindi) faces, best first.
|
||||
fn devanagari_candidates() -> Vec<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
["Nirmala.ttf", "NirmalaB.ttf", "mangal.ttf", "aparaj.ttf", "utsah.ttf"].iter().map(|f| windows_fonts().join(f)).collect()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
[
|
||||
"/System/Library/Fonts/Supplemental/DevanagariMT.ttc",
|
||||
"/System/Library/Fonts/Supplemental/DevanagariSangamMN.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Kohinoor.ttc",
|
||||
"/System/Library/Fonts/Kohinoor.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
} else {
|
||||
unix_fonts(&[
|
||||
"google-noto/NotoSansDevanagariUI-Regular.ttf",
|
||||
"google-noto/NotoSansDevanagari-Regular.ttf",
|
||||
"google-noto-vf/NotoSansDevanagari[wght].ttf",
|
||||
"truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||
"truetype/noto/NotoSansDevanagariUI-Regular.ttf",
|
||||
"noto/NotoSansDevanagari-Regular.ttf",
|
||||
"noto/NotoSansDevanagariUI-Regular.ttf",
|
||||
"lohit-devanagari-fonts/Lohit-Devanagari.ttf",
|
||||
"truetype/lohit-devanagari/Lohit-Devanagari.ttf",
|
||||
"truetype/deva/Lohit-Devanagari.ttf",
|
||||
"lohit-hindi/Lohit-Hindi.ttf",
|
||||
"google-droid-sans-fonts/DroidSansDevanagari-Regular.ttf",
|
||||
"samyak-fonts/Samyak-Devanagari.ttf",
|
||||
"gargi/Gargi.ttf",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known locations of faces with broad script coverage, best first.
|
||||
fn candidates(need: Need) -> Vec<PathBuf> {
|
||||
match need {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ThemeKind {
|
||||
@@ -129,8 +129,58 @@ impl Tokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hover and press amounts for chrome controls. The wash eases in; holding the pointer
|
||||
/// darkens it and drops the content one pixel, so a click reads as a key settling.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Press {
|
||||
pub hover: f32,
|
||||
pub down: f32,
|
||||
}
|
||||
|
||||
impl Press {
|
||||
pub fn track(ui: &egui::Ui, resp: &Response) -> Self {
|
||||
let hot = resp.hovered() || resp.is_pointer_button_down_on();
|
||||
let ease = egui::emath::easing::quadratic_out;
|
||||
Self {
|
||||
hover: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-hover"), hot, 0.12, ease),
|
||||
down: ui.ctx().animate_bool_with_time_and_easing(resp.id.with("chrome-down"), resp.is_pointer_button_down_on(), 0.06, ease),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fill(self, rest: Color32, hover: Color32, pressed: Color32) -> Color32 {
|
||||
rest.lerp_to_gamma(hover, self.hover).lerp_to_gamma(pressed, self.down)
|
||||
}
|
||||
|
||||
/// One pixel down at a full press.
|
||||
pub fn offset(self) -> Vec2 {
|
||||
Vec2::new(0.0, self.down)
|
||||
}
|
||||
|
||||
/// The colour [`Self::wash`] paints.
|
||||
pub fn wash_color(self, t: &Tokens, selected: bool) -> Color32 {
|
||||
if selected {
|
||||
self.fill(t.accent_soft, t.accent_soft.lerp_to_gamma(t.accent, 0.16), t.selected)
|
||||
} else {
|
||||
self.fill(Color32::TRANSPARENT, t.hover, t.pressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft wash behind a control. `selected` keeps the accent fill.
|
||||
pub fn wash(self, ui: &egui::Ui, rect: Rect, radius: impl Into<CornerRadius>, selected: bool) {
|
||||
let fill = self.wash_color(&Tokens::get(ui.ctx()), selected);
|
||||
if fill.a() > 0 {
|
||||
ui.painter().rect_filled(rect, radius, fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pointing hand, the cursor for a control that clicks.
|
||||
pub fn hand(resp: Response) -> Response {
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
ctx.set_fonts(font_definitions());
|
||||
install_fonts_for(ctx, CjkPreference::Default);
|
||||
}
|
||||
|
||||
/// The interface language's claim on the CJK fallback faces.
|
||||
@@ -163,6 +213,8 @@ pub fn install_fonts_for(ctx: &egui::Context, cjk: CjkPreference) {
|
||||
|
||||
/// The name of the installed face [`installed_font_definitions`] may add after the embedded ones.
|
||||
pub const SYSTEM_FALLBACK: &str = "system-fallback";
|
||||
/// The name of the installed Devanagari (Hindi) face [`installed_font_definitions`] may add.
|
||||
pub const SYSTEM_DEVANAGARI_FALLBACK: &str = "system-devanagari-fallback";
|
||||
|
||||
/// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face
|
||||
/// already installed on this machine. It only draws characters no embedded face has.
|
||||
@@ -175,13 +227,23 @@ pub const SYSTEM_FALLBACK: &str = "system-fallback";
|
||||
/// - Otherwise it is the last fallback of every family (an Arabic file name in a build without
|
||||
/// craft-fonts).
|
||||
///
|
||||
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves it out.
|
||||
/// An installed Devanagari face, when there is one, ends every family just before that one, so
|
||||
/// Hindi text draws in a build without a Devanagari face.
|
||||
///
|
||||
/// `PDFCRAFT_SYSTEM_FONTS=0` leaves them out.
|
||||
pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
|
||||
#[cfg_attr(target_arch = "wasm32", expect(unused_mut))]
|
||||
let mut fonts = font_definitions_for(cjk == CjkPreference::SimplifiedChinese);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use crate::system_fonts::{Need, fallback};
|
||||
// Before the general installed face, which stays the last fallback outside Chinese.
|
||||
if let Some(data) = crate::system_fonts::devanagari_fallback() {
|
||||
fonts.font_data.insert(SYSTEM_DEVANAGARI_FALLBACK.to_owned(), data);
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push(SYSTEM_DEVANAGARI_FALLBACK.to_owned());
|
||||
}
|
||||
}
|
||||
let need = match cjk {
|
||||
_ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other,
|
||||
CjkPreference::SimplifiedChinese => Need::Hans,
|
||||
@@ -311,6 +373,8 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
v.widgets.hovered.bg_stroke = Stroke::NONE;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.active.bg_fill = t.pressed;
|
||||
// Stock buttons (menus, dialogs) use the same pointing hand as the custom chrome.
|
||||
v.interact_cursor = Some(egui::CursorIcon::PointingHand);
|
||||
v.widgets.open.weak_bg_fill = t.hover;
|
||||
// Crisper text at 100–150 % scaling (#76): glyphs sit on whole pixels instead of being
|
||||
// rendered at quarter-pixel offsets, which egui notes blurs them. In the light theme, a mild
|
||||
@@ -328,6 +392,8 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
s.spacing.menu_margin = egui::Margin::same(6);
|
||||
s.spacing.scroll.bar_width = 8.0;
|
||||
s.spacing.scroll.floating = true;
|
||||
s.spacing.scroll.floating_width = 3.0;
|
||||
s.spacing.scroll.foreground_color = true;
|
||||
s.text_styles.insert(egui::TextStyle::Body, regular(13.0));
|
||||
s.text_styles.insert(egui::TextStyle::Button, regular(13.0));
|
||||
s.text_styles.insert(egui::TextStyle::Small, regular(11.0));
|
||||
@@ -373,6 +439,19 @@ mod tests {
|
||||
x.max(y) / x.min(y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn press_darkens_then_sinks() {
|
||||
let rest = Color32::from_rgb(0xFF, 0xFF, 0xFF);
|
||||
let hover = Color32::from_rgb(0xF0, 0xF0, 0xF3);
|
||||
let pressed = Color32::from_rgb(0xE4, 0xE4, 0xE9);
|
||||
let idle = Press { hover: 0.0, down: 0.0 };
|
||||
assert_eq!(idle.fill(rest, hover, pressed), rest);
|
||||
assert_eq!(idle.offset(), Vec2::ZERO);
|
||||
let held = Press { hover: 1.0, down: 1.0 };
|
||||
assert_eq!(held.fill(rest, hover, pressed), pressed);
|
||||
assert_eq!(held.offset(), Vec2::new(0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_readable_on_every_surface() {
|
||||
// #76: the faintest text (hints, zoom level, captions) was 3.3:1 in the light theme.
|
||||
|
||||
+216
-32
@@ -110,15 +110,22 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label));
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(rect.shrink2(vec2(2.0, 9.0)), CornerRadius::same(6), t.hover);
|
||||
}
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, if active { t.text } else { t.text_muted });
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
let well = rect.shrink2(vec2(2.0, 9.0));
|
||||
if active {
|
||||
let r = Rect::from_min_max(rect.left_bottom() + vec2(11.0, -3.0), rect.right_bottom() - vec2(11.0, 0.0));
|
||||
ui.painter().rect_filled(r, CornerRadius::same(1), t.text);
|
||||
if press.down > 0.01 {
|
||||
ui.painter().rect_filled(well, CornerRadius::same(6), t.pressed.gamma_multiply(press.down));
|
||||
}
|
||||
} else {
|
||||
press.wash(ui, well, 6, false);
|
||||
}
|
||||
resp
|
||||
let body = rect.translate(press.offset());
|
||||
ui.painter().text(body.center(), Align2::CENTER_CENTER, label, font, if active { t.text } else { t.text_muted });
|
||||
if active {
|
||||
let r = Rect::from_min_max(rect.left_bottom() + vec2(10.0, -2.0), rect.right_bottom() - vec2(10.0, 0.0));
|
||||
ui.painter().rect_filled(r, CornerRadius { nw: 1, ne: 1, sw: 0, se: 0 }, t.accent);
|
||||
}
|
||||
theme::hand(resp)
|
||||
}
|
||||
|
||||
/// Rounded pill button; `primary` fills with the accent.
|
||||
@@ -128,14 +135,15 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
let (fill, stroke, text) = if primary {
|
||||
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
|
||||
(press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE)
|
||||
} else {
|
||||
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
|
||||
(press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text)
|
||||
};
|
||||
ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside);
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, text);
|
||||
resp
|
||||
ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text);
|
||||
theme::hand(resp)
|
||||
}
|
||||
|
||||
/// Icon + label, transparent until hovered.
|
||||
@@ -150,12 +158,12 @@ pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f3
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon);
|
||||
ui.painter().text(rect.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
||||
resp
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon);
|
||||
ui.painter().text(body.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
||||
theme::hand(resp)
|
||||
}
|
||||
|
||||
/// A search-field lookalike that opens the command palette.
|
||||
@@ -163,8 +171,10 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder));
|
||||
let fill = if resp.hovered() { t.hover } else { t.field };
|
||||
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
let fill = press.fill(t.field, t.hover, t.pressed);
|
||||
let edge = t.border.lerp_to_gamma(t.text_muted, press.hover * 0.35);
|
||||
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted);
|
||||
let shortcut = crate::commands::command_shortcut_label(ui.ctx(), "view.palette");
|
||||
let shortcut = ui.painter().layout_no_wrap(shortcut, theme::regular(11.5), t.text_faint);
|
||||
@@ -276,6 +286,95 @@ pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into<String>, font: e
|
||||
ui.painter().galley(rect.min, galley, color);
|
||||
}
|
||||
|
||||
/// Text wrapped to the available width, for text people write (comments, replies). A paragraph
|
||||
/// in a right-to-left script (Persian, Arabic, Hebrew) reads right to left and sits against the
|
||||
/// right edge ([`crate::bidi::wrap`]); text without one is an ordinary wrapping label.
|
||||
/// `truncate` keeps the first line only, with an ellipsis where the text goes on.
|
||||
pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, truncate: bool) -> Response {
|
||||
if !crate::bidi::has_rtl(text) {
|
||||
let mut label = egui::Label::new(egui::RichText::new(text).font(font).color(color)).wrap();
|
||||
if truncate {
|
||||
label = label.truncate();
|
||||
}
|
||||
return ui.add(label);
|
||||
}
|
||||
let width = ui.available_width().max(1.0);
|
||||
let lines = wrapped_lines(ui, text, &font, width, truncate);
|
||||
let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, ui.painter().layout_no_wrap(line.text.clone(), font.clone(), color))).collect();
|
||||
let height = galleys.iter().map(|(_, g)| g.size().y).sum();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
// Painted text only: the accessibility name keeps the logical order.
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
let mut y = rect.top();
|
||||
for (rtl, galley) in galleys {
|
||||
let x = if rtl { rect.right() - galley.size().x } else { rect.left() };
|
||||
let h = galley.size().y;
|
||||
ui.painter().galley(egui::pos2(x, y), galley, color);
|
||||
y += h;
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
const ELLIPSIS: &str = "\u{2026}";
|
||||
|
||||
/// Wrapped right-to-left text kept between frames. Wrapping measures many candidate lines, so
|
||||
/// re-wrapping every comment on every frame would make a panel of long comments slow; the
|
||||
/// result depends only on the text, font, width, scale and collapsed state, so it is looked up.
|
||||
#[derive(Clone, Default)]
|
||||
struct WrapCache {
|
||||
lines: std::collections::HashMap<u64, std::sync::Arc<[crate::bidi::Line]>>,
|
||||
}
|
||||
|
||||
/// How many wrapped texts the cache holds before it starts over (each costs its text's size).
|
||||
const WRAP_CACHE_MAX: usize = 256;
|
||||
|
||||
/// A collapsed comment shows one line, so no more than this many characters are wrapped for it:
|
||||
/// a long comment doesn't cost a full wrap just to show its start.
|
||||
const COLLAPSED_WRAP_CHARS: usize = 1024;
|
||||
|
||||
/// The lines `wrapped_text` paints for a right-to-left `text`, from the cache when the same text
|
||||
/// was wrapped the same way before.
|
||||
fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, truncate: bool) -> std::sync::Arc<[crate::bidi::Line]> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
(text, font, width.to_bits(), ui.ctx().pixels_per_point().to_bits(), truncate).hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
let cache_id = egui::Id::new("pdfcraft-rtl-wrap-cache");
|
||||
if let Some(hit) = ui.ctx().data_mut(|d| d.get_temp_mut_or_default::<WrapCache>(cache_id).lines.get(&key).cloned()) {
|
||||
return hit;
|
||||
}
|
||||
// Collapsed: only the start is wrapped (cut at a char boundary, so the slice can't fail).
|
||||
let (measured, cut) = match text.char_indices().nth(COLLAPSED_WRAP_CHARS) {
|
||||
Some((at, _)) if truncate => (text.get(..at).unwrap_or(text), true),
|
||||
_ => (text, false),
|
||||
};
|
||||
let measure = |f: &mut egui::epaint::text::FontsView<'_>, s: &str| f.layout_no_wrap(s.to_owned(), font.clone(), Color32::WHITE).size().x;
|
||||
let mut lines = ui.fonts_mut(|f| {
|
||||
let room = if truncate { width - measure(f, ELLIPSIS) } else { width };
|
||||
crate::bidi::wrap(measured, |s| measure(f, s) <= room)
|
||||
});
|
||||
if truncate && (lines.len() > 1 || cut) {
|
||||
lines.truncate(1);
|
||||
if let Some(line) = lines.first_mut() {
|
||||
// The ellipsis goes where the line's reading ends: the left of a right-to-left line.
|
||||
if line.rtl {
|
||||
line.text.insert_str(0, ELLIPSIS);
|
||||
} else {
|
||||
line.text.push_str(ELLIPSIS);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lines: std::sync::Arc<[crate::bidi::Line]> = lines.into();
|
||||
ui.ctx().data_mut(|d| {
|
||||
let cache = &mut d.get_temp_mut_or_default::<WrapCache>(cache_id).lines;
|
||||
if cache.len() >= WRAP_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, lines.clone());
|
||||
});
|
||||
lines
|
||||
}
|
||||
|
||||
/// Transient message at the bottom centre.
|
||||
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let Some((msg, start)) = app.toast.clone() else { return };
|
||||
@@ -449,19 +548,24 @@ pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Re
|
||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more));
|
||||
let radius = CornerRadius::same(15);
|
||||
ui.painter().rect(rect, radius, t.card, Stroke::new(1.2, t.text_muted), egui::StrokeKind::Inside);
|
||||
ui.painter().rect(rect, radius, t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 };
|
||||
let right = CornerRadius { nw: 0, sw: 0, ne: 15, se: 15 };
|
||||
for (r, resp, corners) in [(main_rect, &main, left), (more_rect, &arrow, right)] {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink(1.2), corners, t.hover);
|
||||
let main_press = theme::Press::track(ui, &main);
|
||||
let arrow_press = theme::Press::track(ui, &arrow);
|
||||
for (r, press, corners) in [(main_rect, main_press, left), (more_rect, arrow_press, right)] {
|
||||
let fill = press.fill(Color32::TRANSPARENT, t.hover, t.pressed);
|
||||
if fill.a() > 0 {
|
||||
ui.painter().rect_filled(r.shrink(1.2), corners, fill);
|
||||
}
|
||||
}
|
||||
let body = rect.translate(main_press.offset());
|
||||
let more_body = more_rect.translate(arrow_press.offset());
|
||||
ui.painter().vline(more_rect.left(), rect.y_range().shrink(7.0), Stroke::new(1.0, t.text_muted));
|
||||
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text);
|
||||
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
||||
crate::icons::paint(ui, Rect::from_center_size(more_rect.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text);
|
||||
(main.on_hover_text(label), arrow.on_hover_text(more))
|
||||
crate::icons::paint(ui, Rect::from_min_size(body.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text);
|
||||
ui.painter().text(body.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
||||
crate::icons::paint(ui, Rect::from_center_size(more_body.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text);
|
||||
(theme::hand(main).on_hover_text(label), theme::hand(arrow).on_hover_text(more))
|
||||
}
|
||||
|
||||
/// A pill button with an icon (primary = filled accent).
|
||||
@@ -471,15 +575,17 @@ pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> R
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
let (fill, stroke, text) = if primary {
|
||||
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
|
||||
(press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE)
|
||||
} else {
|
||||
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
|
||||
(press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text)
|
||||
};
|
||||
ui.painter().rect(rect, CornerRadius::same(15), fill, stroke, egui::StrokeKind::Inside);
|
||||
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, text);
|
||||
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, text);
|
||||
resp
|
||||
let body = rect.translate(press.offset());
|
||||
crate::icons::paint(ui, Rect::from_min_size(body.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, text);
|
||||
ui.painter().text(body.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, text);
|
||||
theme::hand(resp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -617,6 +723,84 @@ mod tests {
|
||||
assert!((again.width() - short.width()).abs() < 1.0, "long notices must not impose a permanent minimum width");
|
||||
}
|
||||
|
||||
/// The text shapes `wrapped_text` paints in a 120 pt wide column at x = 0.
|
||||
fn wrapped_shapes(text: &str, truncate: bool) -> Vec<(Pos2, Rect, String)> {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let column = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 400.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(column), |ui| {
|
||||
wrapped_text(ui, text, theme::regular(13.0), Color32::WHITE, truncate);
|
||||
});
|
||||
});
|
||||
let shapes = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| if let egui::Shape::Text(t) = &shape.shape { Some((t.pos, t.galley.rect, t.galley.job.text.clone())) } else { None })
|
||||
.collect();
|
||||
output.drop_without_applying_deltas();
|
||||
shapes
|
||||
}
|
||||
|
||||
/// Right-to-left text is wrapped once and looked up on later frames; the cache stays bounded
|
||||
/// however many comments go by, and a collapsed comment of any length still shows one line.
|
||||
#[test]
|
||||
fn rtl_wrapping_is_cached_and_bounded() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let cache_len =
|
||||
|ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::<WrapCache>(egui::Id::new("pdfcraft-rtl-wrap-cache")).lines.len());
|
||||
let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود";
|
||||
let long = persian.repeat(2_000);
|
||||
let mut first = None;
|
||||
for _ in 0..3 {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let lines = wrapped_lines(ui, persian, &theme::regular(13.0), 120.0, false);
|
||||
assert!(lines.len() > 1);
|
||||
if let Some(prev) = &first {
|
||||
assert!(std::sync::Arc::ptr_eq(prev, &lines), "the second frame reuses the first frame's wrap");
|
||||
}
|
||||
first = Some(lines);
|
||||
let collapsed = wrapped_lines(ui, &long, &theme::regular(13.0), 120.0, true);
|
||||
assert_eq!(collapsed.len(), 1);
|
||||
assert!(collapsed[0].text.starts_with(ELLIPSIS), "{:?}", collapsed[0].text);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
assert_eq!(cache_len(&ctx), 2, "one entry per text and layout");
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for i in 0..WRAP_CACHE_MAX + 50 {
|
||||
wrapped_lines(ui, &format!("{persian} {i}"), &theme::regular(13.0), 120.0, false);
|
||||
}
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
assert!(cache_len(&ctx) <= WRAP_CACHE_MAX, "bounded: {}", cache_len(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_comment_wraps_against_the_right_edge() {
|
||||
let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود";
|
||||
let lines = wrapped_shapes(persian, false);
|
||||
assert!(lines.len() > 1, "{lines:?}");
|
||||
let right = lines.iter().map(|(pos, rect, _)| pos.x + rect.width()).fold(0.0, f32::max);
|
||||
for (pos, rect, text) in &lines {
|
||||
assert!(pos.x >= -0.5 && (pos.x + rect.width() - right).abs() < 0.5, "{text:?} at {pos:?} must be right-aligned");
|
||||
}
|
||||
assert!(right <= 120.5, "lines stay inside the column: {right}");
|
||||
// The first words read on the first line, at the top.
|
||||
let top = lines.iter().min_by(|a, b| a.0.y.total_cmp(&b.0.y)).unwrap();
|
||||
assert!(top.2.contains("این"), "{top:?}");
|
||||
// Collapsed: one line, its ellipsis on the left where right-to-left reading ends.
|
||||
let collapsed = wrapped_shapes(persian, true);
|
||||
assert_eq!(collapsed.len(), 1);
|
||||
assert!(collapsed[0].2.starts_with('\u{2026}'), "{collapsed:?}");
|
||||
// Text without right-to-left script is a plain label, as before.
|
||||
let latin = wrapped_shapes("A short note", false);
|
||||
assert_eq!(latin.len(), 1);
|
||||
assert_eq!(latin[0].2, "A short note");
|
||||
assert!(latin[0].0.x.abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_field_is_at_least_thirty_points_tall() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -200,6 +200,53 @@ fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec<u8>)> {
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_batch_creates_one_pdf_per_source_without_replacing_existing_files() {
|
||||
use pdfcraft_ui_egui::FilePurpose;
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-separate-batch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("notes.pdf"), b"keep this file").unwrap();
|
||||
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
|
||||
let mut files = mixed_files(&app);
|
||||
files.push(("notes.txt".into(), b"another text file".to_vec()));
|
||||
app.use_files(FilePurpose::CreateMultipleSeparate, files);
|
||||
|
||||
assert_eq!(std::fs::read(dir.join("notes.pdf")).unwrap(), b"keep this file");
|
||||
for name in ["notes (2).pdf", "notes (3).pdf", "photo.pdf"] {
|
||||
assert!(std::fs::read(dir.join(name)).unwrap().starts_with(b"%PDF-"), "{name}");
|
||||
}
|
||||
assert!(!dir.join("a (2).pdf").exists(), "an already-PDF input is skipped");
|
||||
assert!(!dir.join("report.pdf").exists(), "an invalid Office file is skipped");
|
||||
assert!(app.views.is_empty(), "batch conversion must not open PDF tabs");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_batch_command_picks_files_and_writes_to_the_chosen_folder() {
|
||||
use egui_kittest::Harness;
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-separate-command-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = dir.join("letter.txt");
|
||||
std::fs::write(&source, b"A separate PDF").unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 720.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.pick_override = Some(vec![source.to_string_lossy().into_owned()]);
|
||||
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
|
||||
app
|
||||
});
|
||||
assert!(h.state_mut().execute("create.multiple_separate"));
|
||||
h.run_steps(2);
|
||||
assert!(std::fs::read(dir.join("letter.pdf")).unwrap().starts_with(b"%PDF-"));
|
||||
assert!(h.state().views.is_empty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_files_open_as_one_document_in_the_page_grid() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
@@ -779,6 +779,85 @@ fn edit_menu_names_the_step_to_undo() {
|
||||
|
||||
// ── Combine / insert from file / extract / split ──────────────────────────────────────────────
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name`.
|
||||
fn form_pdf(name: &str) -> Vec<u8> {
|
||||
form_pdf_pages(name, 1)
|
||||
}
|
||||
|
||||
/// A PDF of `pages` pages whose form holds one text field named `name` (on the first page).
|
||||
fn form_pdf_pages(name: &str, pages: usize) -> Vec<u8> {
|
||||
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 3 + i)).collect();
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
|
||||
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 400] >>", kids.join(" ")),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
|
||||
];
|
||||
for _ in 1..pages {
|
||||
objs.push("<< /Type /Page /Parent 2 0 R >>".to_string()); // objects after the field's number
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// organize.field-name-conflicts: the Combine tab warns, before anything is combined, that a
|
||||
/// file's form fields collide with earlier files' and will be renamed, naming the new names.
|
||||
#[test]
|
||||
fn combine_list_warns_that_colliding_field_names_will_be_renamed() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), form_pdf("Text1")), ("two.pdf".into(), form_pdf("Text1"))]);
|
||||
});
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("will be renamed");
|
||||
h.get_by_label_contains("Text1 → Text1-1");
|
||||
}
|
||||
|
||||
/// A file split into parts (its pages moved among other files') is combined as one file, once —
|
||||
/// so the preview must not warn that its field names collide with their own.
|
||||
#[test]
|
||||
fn combine_list_does_not_warn_that_a_split_file_collides_with_itself() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), form_pdf_pages("Text1", 2))]);
|
||||
});
|
||||
h.run_steps(3);
|
||||
// one.pdf in two parts — pages 1 and 2, sharing a group, as moving its pages apart leaves it.
|
||||
let mut part = h.state().combine_draft[0].clone();
|
||||
part.id += 1000;
|
||||
h.state_mut().combine_draft[0].range = "1".into();
|
||||
part.range = "2".into();
|
||||
h.state_mut().combine_draft.push(part);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label_contains("will be renamed").is_none(), "one file, however many parts, cannot collide with itself");
|
||||
}
|
||||
|
||||
/// Copying a page with a field and pasting it back into the same document is a duplicate: the
|
||||
/// copy keeps the field's name rather than becoming Text1-1.
|
||||
#[test]
|
||||
fn pasting_pages_into_their_own_document_keeps_field_names() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("form.pdf", None, form_pdf("Text1")).unwrap();
|
||||
app.views[0].select_pages(&[0]);
|
||||
assert!(app.execute("page.copy"));
|
||||
assert!(app.execute("page.paste"));
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2, "pasted");
|
||||
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
fn texts_of(app: &PdfCraftApp, tab: usize) -> Vec<String> {
|
||||
let doc = app.session.get(app.views[tab].id).unwrap();
|
||||
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
|
||||
@@ -1235,3 +1235,235 @@ fn select_tool_picks_up_a_signature_over_a_form_field() {
|
||||
click(&mut h, 270.0, 190.0);
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city"));
|
||||
}
|
||||
|
||||
/// A synthetic phone photo of a signature on paper: a dark table along the left edge, a sheet
|
||||
/// with a lighting gradient and a soft shadow, and a ballpoint-blue signature in two words.
|
||||
fn signature_photo(test: &str) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-signature-photo-{test}-{}.png", std::process::id()));
|
||||
let image = image::RgbImage::from_fn(800, 600, |x, y| {
|
||||
if x < 60 {
|
||||
return image::Rgb([70, 55, 45]);
|
||||
}
|
||||
let light = 235.0 - x as f32 * 0.04 - if (500..700).contains(&x) && y > 380 { 45.0 } else { 0.0 };
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
let wave = |x0: f32, x1: f32| (x0..x1).contains(&fx) && (fy - (300.0 + 25.0 * ((fx - x0) / 18.0).sin())).abs() < 3.0;
|
||||
if wave(180.0, 380.0) || wave(420.0, 640.0) {
|
||||
image::Rgb([30, 40, 110])
|
||||
} else {
|
||||
let v = light.clamp(0.0, 255.0) as u8;
|
||||
image::Rgb([v, v, v.saturating_sub(6)])
|
||||
}
|
||||
});
|
||||
image.save(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn choose_photo(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) {
|
||||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||||
h.get_by_label("Choose photo…").click();
|
||||
h.run_steps(3);
|
||||
settle_scan(h);
|
||||
}
|
||||
|
||||
/// Scans run on a worker thread: step frames until the answer has been picked up.
|
||||
fn settle_scan(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for _ in 0..1000 {
|
||||
if !h.state().signature_draft.scan.as_ref().is_some_and(|s| s.scanning()) {
|
||||
h.run_steps(1);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.run_steps(1);
|
||||
}
|
||||
panic!("the signature scan did not finish");
|
||||
}
|
||||
|
||||
/// A transparent signature in the ink's colour: no paper, table or shadow is left.
|
||||
fn assert_clean_signature(image: &pdfcraft_engine::SignatureImage) {
|
||||
let [w, h] = image.size();
|
||||
assert!(w > 200 && w > h * 2, "both words are framed and trimmed: {w}×{h}");
|
||||
let rgba = image.rgba();
|
||||
let opaque: Vec<&[u8; 4]> = rgba.as_chunks::<4>().0.iter().filter(|p| p[3] > 128).collect();
|
||||
assert!(!opaque.is_empty());
|
||||
assert!(opaque.iter().all(|p| p[2] > p[0]), "strokes keep their blue ink");
|
||||
assert!(rgba.as_chunks::<4>().0.iter().filter(|p| p[3] == 0).count() > rgba.len() / 8, "the paper is transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered() {
|
||||
let _gpu = gpu();
|
||||
let path = signature_photo("scan");
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.signature");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Scan").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().signature_draft.scan_mode);
|
||||
assert!(h.state().signature_draft.saved().is_none(), "Apply needs a photo");
|
||||
choose_photo(&mut h, &path);
|
||||
let scan = h.state().signature_draft.scan.clone().expect("the photo is loaded");
|
||||
assert_eq!(scan.photo.size(), [800, 600]);
|
||||
let frame = scan.frame;
|
||||
assert!(frame.x > 60 && frame.x < 190 && frame.x + frame.width > 630, "the frame holds both words, not the table: {frame:?}");
|
||||
assert_clean_signature(h.state().signature_draft.image.as_ref().expect("a cleaned signature"));
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/scan-signature-dialog.png")).unwrap();
|
||||
}
|
||||
|
||||
// Drawing a frame around the first word only leaves the second out.
|
||||
let area = h.get_by_label("Photo").rect();
|
||||
let to_screen = |x: f32, y: f32| {
|
||||
let fit = area.shrink(8.0);
|
||||
let scale = (fit.width() / 800.0).min(fit.height() / 600.0);
|
||||
let origin = fit.center() - egui::vec2(400.0, 300.0) * scale;
|
||||
origin + egui::vec2(x, y) * scale
|
||||
};
|
||||
let (a, b) = (to_screen(110.0, 190.0), to_screen(400.0, 370.0));
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.drag_at(a);
|
||||
h.run_steps(1);
|
||||
for k in 1..=5 {
|
||||
h.hover_at(a + (b - a) * (k as f32 / 5.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(b);
|
||||
h.run_steps(3);
|
||||
settle_scan(&mut h);
|
||||
let one_word = h.state().signature_draft.scan.as_ref().unwrap().frame;
|
||||
assert!(one_word.x + one_word.width < 420, "{one_word:?}");
|
||||
let [w, _] = h.state().signature_draft.image.as_ref().unwrap().size();
|
||||
assert!(w < 300, "only the first word is left: {w}");
|
||||
assert!(h.state().signature_draft.saved().is_some(), "the full result is ready after the drag");
|
||||
|
||||
// Turning the photo turns the frame with it; turning back restores both.
|
||||
h.get_by_label("Rotate clockwise").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().signature_draft.scan.as_ref().unwrap().photo.size(), [600, 800]);
|
||||
h.get_by_label("Rotate counterclockwise").click();
|
||||
h.run_steps(2);
|
||||
let scan = h.state().signature_draft.scan.clone().unwrap();
|
||||
assert_eq!((scan.photo.size(), scan.frame), ([800, 600], one_word));
|
||||
|
||||
// A photo with nothing in the frame says so instead of offering an empty signature.
|
||||
{
|
||||
let d = &mut h.state_mut().signature_draft;
|
||||
let scan = d.scan.as_mut().unwrap();
|
||||
scan.frame = pdfcraft_engine::PhotoRect { x: 650, y: 40, width: 120, height: 100 };
|
||||
scan.rescan(&mut d.image, true);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.state().signature_draft.saved().is_none());
|
||||
assert!(h.state().signature_draft.scan.as_ref().unwrap().problem.as_ref().unwrap().contains("No signature was found"));
|
||||
h.get_by_label("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.");
|
||||
{
|
||||
let d = &mut h.state_mut().signature_draft;
|
||||
let scan = d.scan.as_mut().unwrap();
|
||||
scan.frame = frame;
|
||||
scan.rescan(&mut d.image, true);
|
||||
}
|
||||
h.run_steps(2);
|
||||
|
||||
h.get_by_label("Apply").click();
|
||||
h.run_steps(3);
|
||||
let Some(SavedSig::Image(saved)) = h.state().signature.clone() else { panic!("the scan is saved as an image signature") };
|
||||
assert_clean_signature(&saved);
|
||||
click(&mut h, 40.0, 250.0);
|
||||
assert_eq!(items(&h).len(), 1);
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.annotations[0].subtype, "Stamp");
|
||||
|
||||
// It is remembered like any image signature, without the photo or its path.
|
||||
let settings = h.state().persist();
|
||||
assert!(!settings.contains(&path.to_string_lossy().to_string()));
|
||||
assert!(settings.len() < 200_000, "only the trimmed signature is kept: {} bytes", settings.len());
|
||||
let mut again = PdfCraftApp::new();
|
||||
again.set_option("language", "en").unwrap();
|
||||
again.restore(&settings);
|
||||
assert_eq!(again.signature, h.state().signature);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_cancel_errors_and_stale_answers_preserve_saved_signatures() {
|
||||
let path = signature_photo("scan-cancel");
|
||||
let mut h = harness();
|
||||
let original = SavedSig::Typed("Ada Lovelace".into());
|
||||
h.state_mut().signature = Some(original.clone());
|
||||
h.state_mut().execute("sign.fill.signature.change");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Scan").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().pick_override = Some(Vec::new());
|
||||
h.get_by_label("Choose photo…").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().signature_draft.scan.is_none(), "a cancelled pick loads nothing");
|
||||
choose_photo(&mut h, &path);
|
||||
let loaded = h.state().signature_draft.clone();
|
||||
assert!(loaded.image.is_some());
|
||||
let broken = std::env::temp_dir().join(format!("pdfcraft-signature-photo-broken-{}.jpg", std::process::id()));
|
||||
std::fs::write(&broken, b"\xFF\xD8\xFF not a JPEG").unwrap();
|
||||
choose_photo(&mut h, &broken);
|
||||
assert_eq!(h.state().signature_draft, loaded, "an invalid photo keeps the previous one");
|
||||
assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't use the photo"));
|
||||
// A photo picked for the Scan tab does not land in the Image tab.
|
||||
h.state_mut().pick_override = Some(vec![path.to_string_lossy().into_owned()]);
|
||||
h.get_by_label("Choose photo…").click();
|
||||
h.run_steps(1);
|
||||
h.state_mut().signature_draft.scan = None;
|
||||
h.state_mut().signature_draft.image = None;
|
||||
h.state_mut().signature_draft.scan_mode = false;
|
||||
h.state_mut().signature_draft.image_mode = true;
|
||||
h.run_steps(3);
|
||||
assert!(h.state().signature_draft.image.is_none() && h.state().signature_draft.scan.is_none());
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().signature, Some(original));
|
||||
assert!(items(&h).is_empty());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(broken).unwrap();
|
||||
}
|
||||
|
||||
/// A file dropped on the window, as the windowing layer hands it over.
|
||||
#[derive(Debug)]
|
||||
struct Dropped(std::path::PathBuf, Vec<u8>);
|
||||
|
||||
impl egui::DroppedFile for Dropped {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
Ok(self.1.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() {
|
||||
let photo = signature_photo("drop");
|
||||
let mut h = harness();
|
||||
let tabs = h.state().views.len();
|
||||
h.state_mut().execute("sign.fill.signature");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Scan").click();
|
||||
h.run_steps(2);
|
||||
// From a file manager (an absolute path) and from a browser-like source (bytes only).
|
||||
for file in [Dropped(photo.clone(), Vec::new()), Dropped("photo.png".into(), std::fs::read(&photo).unwrap())] {
|
||||
h.state_mut().signature_draft.scan = None;
|
||||
h.input_mut().dropped_files.push(std::sync::Arc::new(file));
|
||||
h.run_steps(3);
|
||||
settle_scan(&mut h);
|
||||
assert!(h.state().signature_draft.scan.is_some(), "the photo is scanned");
|
||||
assert!(h.state().signature_draft.image.is_some());
|
||||
}
|
||||
h.get_by_label("Image").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().signature_draft.image = None;
|
||||
h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("photo.png".into(), std::fs::read(&photo).unwrap())));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().signature_draft.image.is_some(), "the Image tab takes the picture as it is");
|
||||
h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped("broken.png".into(), b"broken".to_vec())));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().signature_draft.image.is_some(), "an invalid file keeps the previous draft");
|
||||
assert!(h.state().toast.as_ref().unwrap().0.contains("Couldn't import"));
|
||||
assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog");
|
||||
std::fs::remove_file(photo).unwrap();
|
||||
}
|
||||
|
||||
@@ -147,6 +147,29 @@ fn system_fallback_fills_missing_scripts() {
|
||||
assert!(layout_widths(&mut fonts, ARABIC).iter().all(|w| w.is_finite() && *w > 0.0));
|
||||
}
|
||||
|
||||
/// On a machine with a suitable installed Devanagari font, the installed definitions include
|
||||
/// it and Hindi text has glyphs; the embedded-only definitions never name it.
|
||||
#[test]
|
||||
fn system_fallback_covers_devanagari() {
|
||||
assert!(!theme::font_definitions().font_data.contains_key(theme::SYSTEM_DEVANAGARI_FALLBACK));
|
||||
let defs = theme::installed_font_definitions(theme::CjkPreference::Default);
|
||||
if !defs.font_data.contains_key(theme::SYSTEM_DEVANAGARI_FALLBACK) {
|
||||
eprintln!("skipping system_fallback_covers_devanagari: no installed Devanagari fallback font (or PDFCRAFT_SYSTEM_FONTS=0)");
|
||||
assert!(defs.families.values().all(|stack| !stack.iter().any(|n| n == theme::SYSTEM_DEVANAGARI_FALLBACK)));
|
||||
return;
|
||||
}
|
||||
for (family, stack) in &defs.families {
|
||||
assert!(stack.iter().any(|n| n == theme::SYSTEM_DEVANAGARI_FALLBACK), "{family:?}: {stack:?}");
|
||||
assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_DEVANAGARI_FALLBACK).count(), 1, "{family:?}");
|
||||
}
|
||||
let mut fonts = Fonts::new(TextOptions::default(), defs);
|
||||
const DEVANAGARI: &str = "हिंदी पाठ सम्पादन";
|
||||
for id in families() {
|
||||
assert!(fonts.has_glyphs(&id, DEVANAGARI), "{id:?} lacks {DEVANAGARI}");
|
||||
}
|
||||
assert!(layout_widths(&mut fonts, DEVANAGARI).iter().all(|w| w.is_finite() && *w > 0.0));
|
||||
}
|
||||
|
||||
/// The Han characters of a catalog's translations.
|
||||
fn catalog_han(catalog: &str) -> String {
|
||||
let mut han: Vec<char> = catalog
|
||||
|
||||
@@ -803,10 +803,17 @@ fn required_radio_buttons_get_a_round_red_border() {
|
||||
let p = egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0));
|
||||
*img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32)
|
||||
};
|
||||
let red = |p: image::Rgba<u8>| p[0] > 180 && p[1] < 100 && p[2] < 100;
|
||||
// Fit width puts about 4 px in a point: 0.2 to 0.3 pt from an edge is inside a 2 px border.
|
||||
assert!(red(at(50.2, 320.0)), "the left of the circle is red: {:?}", at(50.2, 320.0));
|
||||
assert!(!red(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7));
|
||||
// Fit width is about 4 px in a point, and the stroke is 2 px. Chrome width can shift
|
||||
// that stroke by a fraction of a pixel, so one sample may land on its antialiased edge
|
||||
// (still red, mixed with the field tint) instead of the solid stroke.
|
||||
let stroke = |p: image::Rgba<u8>| p[0] > 180 && p[2] < 140 && i16::from(p[0]) - i16::from(p[2]) > 80;
|
||||
assert!(
|
||||
[0.05_f32, 0.2].into_iter().any(|dx| stroke(at(50.0 + dx, 320.0))),
|
||||
"the left of the circle is red: {:?} {:?}",
|
||||
at(50.05, 320.0),
|
||||
at(50.2, 320.0)
|
||||
);
|
||||
assert!(!stroke(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7));
|
||||
}
|
||||
|
||||
/// Acrobat's Highlight existing fields is a preference, not a per-document choice: turned on
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (encrypted output opens in Acrobat, #774; XFDF fixes #807, #809, #819) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Word invoice export, #773; encrypted output opens in Acrobat, #774; XFDF fixes #807, #809, #819) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from
|
||||
its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation
|
||||
@@ -73,13 +73,13 @@ features total).
|
||||
| PostScript / EPS (Distiller) | yes | no | none | `create.distiller` planned (P3) |
|
||||
| Markdown | no | no | none | `create.from-markdown` planned (P2; not an Acrobat feature) |
|
||||
| Scanner | yes | no | none | `ocr.scanner-acquire` planned |
|
||||
| Several files at once | yes | yes (PDFs, images and text) | good | `create.create-multiple` |
|
||||
| Several files at once | yes | yes: combine PDFs, images and text into one document, or create a separate PDF for each non-PDF source (GUI and `doc_create_multiple mode=separate`); existing outputs get numbered names | good for supported source types | `create.create-multiple`, `separate_batch_creates_one_pdf_per_source_without_replacing_existing_files` |
|
||||
|
||||
## Export PDF to
|
||||
|
||||
| Target | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|
||||
|---|---|---|---|---|
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing document: paragraphs, headings, bold/italic, colour, images, tables | **basic** (#773: layout lost, fake borders, duplicated pages) | `create.export-docx` partial |
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
|
||||
| Word 97–2003 (`.doc`) | yes | no | none | |
|
||||
| Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned |
|
||||
| PowerPoint (`.pptx`) | yes | no | none | `create.export-pptx` planned |
|
||||
@@ -109,5 +109,6 @@ features total).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. |
|
||||
| 2026-10-11 | minor | Encrypted PDF row: written files open in Acrobat DC (#774, fixed by #836). FDF row: #807, #809 and #819 fixed |
|
||||
| 2026-10-10 | major | Created from Acrobat 26.002.21931's `CFBundleDocumentTypes`, Acrobat's public Create/Export documentation, and PdfCraft's code and checklist |
|
||||
|
||||
+7
-2
@@ -1,5 +1,7 @@
|
||||
# Where PdfCraft falls short of Acrobat Pro
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents
|
||||
@@ -152,8 +154,10 @@ update the numbers.
|
||||
|
||||
- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from Office,
|
||||
HTML, web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export.
|
||||
- **Evidence:** `create.export-docx` partial; [#773](https://github.com/storytold/pdfcraft/issues/773)
|
||||
(an invoice converted to Word loses its layout, gains table borders and duplicates pages);
|
||||
- **Evidence:** `create.export-docx` partial; the synthetic invoice regression for
|
||||
[#773](https://github.com/storytold/pdfcraft/issues/773) now covers absent inferred borders,
|
||||
floating watermark text and per-page section sizes. Exact font matching, vector rules and
|
||||
broader layout fidelity remain incomplete;
|
||||
`create.export-xlsx`, `create.export-pptx`, `create.from-office`, `create.from-html` planned.
|
||||
- **Impact:** "Export PDF" and "Create PDF" are two of Acrobat's most used tools.
|
||||
- **Gate:** lossy or missing sub-cases of the convert-and-share workflow (partial, not blocking).
|
||||
@@ -305,6 +309,7 @@ update the numbers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Word invoice failure #773 has synthetic regression coverage; broader Office conversion remains gap 8. |
|
||||
| 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged |
|
||||
| 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) |
|
||||
| 2026-10-11 | minor | Closed gap 1 (#774, fixed by #836; numbers kept, its interop suite moved to gap 12); gap 6 lists the fixed #799–#821 batch; gap 3 and 16 updated for #844 (same-page move), #740 (column select) and #759 (1:1 touchpad scrolling) |
|
||||
|
||||
+2
-1
@@ -90,7 +90,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import, web printing |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
|
||||
@@ -140,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. |
|
||||
| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M3: Windows' Animation effects setting is followed: wheel scrolling and zooming without easing, egui's animations off. Preferences ▸ Smooth scrolling (Use system setting, On, Off) overrides it for the wheel. macOS, Linux and the browser aren't asked yet. Estimates unchanged. |
|
||||
| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. |
|
||||
|
||||
@@ -3067,11 +3067,11 @@ title = "Rename or merge colliding field names when combining"
|
||||
area = "D"
|
||||
tier = "P1"
|
||||
milestone = "M4"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["page.insert", "page.combine"]
|
||||
tools = ["doc_combine"]
|
||||
evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"]
|
||||
notes = "Collisions are found by fully-qualified name (parent.T.child), counting names that hang only off page widgets even when /AcroForm never lists them, and also between two incoming fields. The colliding field's own /T takes the first free variant (Text1-1, Text1-2, …), children keep their place, and nothing is silent: inserting into an open document notes each rename in the repair log (warning that scripts referring to the old names were not rewritten) and the undo step's label counts them; doc_combine lists renamed_fields in its result; the Combine tab's Warnings column previews the renames per file before combining. field_conflicts \"merge\" on doc_combine (and the engine policy) keeps the older one-field behaviour. Still missing: the Combine tab's preview considers whole files, not a narrowed page range; the UI offers no merge choice (it always renames); Create ▸ Multiple files renames without reporting; scripts and XFA bindings that reference old names are never rewritten; Acrobat's behaviour not observed."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.struct-tree-merge"
|
||||
@@ -5255,15 +5255,15 @@ notes = "local date in Preferences ▸ Date format (default m/d/yyyy; 12 presets
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-sign-signature"
|
||||
title = "Fill & Sign: typed, drawn or image signature and initials"
|
||||
title = "Fill & Sign: typed, drawn, image or scanned signature and initials"
|
||||
area = "F"
|
||||
tier = "P0"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
|
||||
tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075)"
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)"
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-sign-flatten"
|
||||
@@ -6934,8 +6934,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: columns, fonts, colour per run within a paragraph, exact layout mode."
|
||||
evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
|
||||
notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
|
||||
Reference in New Issue
Block a user