mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:35:21 +00:00
Merge remote-tracking branch 'upstream/main' into fix/selection-bar-fits-view
# Conflicts: # crates/ui-egui/tests/it/comments.rs
This commit is contained in:
Generated
+41
-36
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4232,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4244,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4255,7 +4255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4306,11 +4306,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4333,7 +4333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
@@ -4353,7 +4353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4365,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4374,6 +4374,8 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"proptest",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
@@ -4381,7 +4383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4393,6 +4395,7 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-audit",
|
||||
"pdfcraft-compare",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-create",
|
||||
"pdfcraft-edit",
|
||||
@@ -4423,14 +4426,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4443,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4453,7 +4456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4466,11 +4469,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4478,7 +4481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4491,14 +4494,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4509,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4525,11 +4528,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4540,7 +4543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4550,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4561,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4573,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4590,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4609,7 +4612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4645,11 +4648,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4673,6 +4676,7 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4683,6 +4687,7 @@ dependencies = [
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"pollster 1.0.1",
|
||||
"proptest",
|
||||
"rfd",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4701,7 +4706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4713,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4727,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+34
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -26,39 +26,39 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.16.1" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.16.1" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.16.1" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.16.1" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.16.1" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.16.1" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.16.1" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.16.1" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.16.1" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.16.1" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.16.1" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.16.1" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.16.1" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.16.1" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.16.1" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.16.1" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.16.1" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.16.1" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.16.1" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.16.1" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.16.1" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -547,9 +547,18 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+15
@@ -1,5 +1,11 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
|
||||
@@ -213,6 +219,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
|
||||
|
||||
- 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged.
|
||||
|
||||
- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged.
|
||||
|
||||
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
|
||||
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
|
||||
|
||||
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
|
||||
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
|
||||
@@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec
|
||||
and flips. Angles that are not multiples of 90 are refused without changing the document or
|
||||
its undo/redo history.
|
||||
|
||||
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
|
||||
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
|
||||
|
||||
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
|
||||
|
||||
## Conventions
|
||||
@@ -199,6 +202,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
|
||||
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
|
||||
and [image editing](../../docs/editing-images.md).
|
||||
|
||||
### Superscript and subscript normalization
|
||||
|
||||
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
|
||||
change the font size or baseline. For characters that have such forms, uniformly plain text
|
||||
toggles to the requested script, and uniformly scripted text toggles back to plain text.
|
||||
Characters without a matching form stay unchanged and do not determine the selection's state.
|
||||
|
||||
A selection mixing plain and target-script characters normalizes to the requested script.
|
||||
For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript
|
||||
changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original
|
||||
mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text,
|
||||
including its original mixture. Save/reopen preserves the resulting normalized characters.
|
||||
|
||||
## Opening local GoToR links
|
||||
|
||||
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
|
||||
|
||||
@@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
|
||||
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
|
||||
const MAX_ALIGNS: usize = 4096;
|
||||
|
||||
/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
|
||||
/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`.
|
||||
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
|
||||
let Some(v) = a.get("aligns") else { return Ok(None) };
|
||||
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
|
||||
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?;
|
||||
if items.len() > MAX_ALIGNS {
|
||||
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
|
||||
}
|
||||
@@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s == "inherit" => Ok(None),
|
||||
Value::String(s) => parse_align(s).map(Some),
|
||||
_ => Err(bad("each entry of aligns is left, center, right, justify or null")),
|
||||
_ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Some)
|
||||
|
||||
@@ -12,6 +12,35 @@ pub enum Content {
|
||||
Png { data: Vec<u8>, width: u32, height: u32 },
|
||||
}
|
||||
|
||||
/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point
|
||||
/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is
|
||||
/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by
|
||||
/// an affine map, so three corners of the crop box pin the result down.
|
||||
fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> {
|
||||
let [a, b, c, d, e, f] = m;
|
||||
let det = a * d - b * c;
|
||||
if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 {
|
||||
return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into()));
|
||||
}
|
||||
let moved = |x: f32, y: f32| {
|
||||
let [vx, vy] = info.user_to_view(x, y).map(f64::from);
|
||||
info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from)
|
||||
};
|
||||
let [x0, y0, x1, y1] = info.crop;
|
||||
let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0));
|
||||
if w.abs() < 1e-3 || h.abs() < 1e-3 {
|
||||
return Err(ToolError::InvalidArgs("the page has no area to transform in".into()));
|
||||
}
|
||||
let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1));
|
||||
let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h);
|
||||
let (x0, y0) = (f64::from(x0), f64::from(y0));
|
||||
let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0];
|
||||
if !user.iter().all(|v| v.is_finite()) {
|
||||
return Err(ToolError::InvalidArgs("the transform moves the image too far".into()));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
|
||||
@@ -357,7 +386,7 @@ impl Automation {
|
||||
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
|
||||
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
|
||||
});
|
||||
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
|
||||
json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
|
||||
})
|
||||
.collect();
|
||||
json!({ "page": page + 1, "count": list.len(), "images": list })
|
||||
@@ -395,6 +424,16 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"transform" => {
|
||||
let m: Vec<f64> = a
|
||||
.get("matrix")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|x| x.iter().map(Value::as_f64).collect())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
|
||||
let m = <[f64; 6]>::try_from(m)
|
||||
.map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
|
||||
ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?)
|
||||
}
|
||||
"rotate_by" => ImageEdit::RotateBy(
|
||||
a.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
@@ -410,9 +449,18 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
action @ ("flip_horizontal" | "flip_vertical") => {
|
||||
let horizontal = action == "flip_horizontal";
|
||||
let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?;
|
||||
// The tool names displayed axes; the engine names PDF user-space axes.
|
||||
ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) }
|
||||
}
|
||||
"delete" => ImageEdit::Delete,
|
||||
"bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
|
||||
"send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
|
||||
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
|
||||
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
|
||||
"remove_crop" => ImageEdit::RemoveCrop,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
@@ -624,6 +672,7 @@ impl Automation {
|
||||
}
|
||||
out
|
||||
}
|
||||
"text_script" => self.text_script(&a)?,
|
||||
"doc_audit_space" => {
|
||||
let rows: Vec<Value> = self
|
||||
.doc(&a)?
|
||||
@@ -815,4 +864,52 @@ impl Automation {
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
}
|
||||
|
||||
/// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range
|
||||
/// of its editor text, written as `text_edit` writes the paragraph.
|
||||
fn text_script(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::text_script::{Script, toggle};
|
||||
let name = a.str("script")?;
|
||||
let script = match name {
|
||||
"superscript" => Script::Super,
|
||||
"subscript" => Script::Sub,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))),
|
||||
};
|
||||
let page = self.page(a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
let text = {
|
||||
let doc = self.doc(a)?;
|
||||
let blocks = doc.text_blocks(page);
|
||||
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
|
||||
blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?
|
||||
};
|
||||
let range = match a.opt_ints("range")? {
|
||||
None => None,
|
||||
Some(r) => {
|
||||
let bounds = match r.as_slice() {
|
||||
[start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()),
|
||||
_ => None,
|
||||
};
|
||||
let range = bounds
|
||||
.map(|(start, end)| start..end)
|
||||
.filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end));
|
||||
let Some(range) = range else {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries",
|
||||
text.len()
|
||||
)));
|
||||
};
|
||||
Some(range)
|
||||
}
|
||||
};
|
||||
let (toggled, _) = toggle(&text, range.as_ref(), script)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?;
|
||||
let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled });
|
||||
let mut out = match self.call("text_edit", &args)?.into_iter().next() {
|
||||
Some(Content::Json(out)) => out,
|
||||
_ => json!({}),
|
||||
};
|
||||
out["script"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
|
||||
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
@@ -637,7 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
}),
|
||||
&["doc", "page", "text"],
|
||||
@@ -660,7 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
@@ -1378,17 +1378,18 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
"matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 },
|
||||
"path": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "image", "action"],
|
||||
@@ -1428,7 +1429,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"color": { "type": "string", "description": "#rrggbb" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"underline": { "type": "boolean" },
|
||||
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
|
||||
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
|
||||
@@ -1438,6 +1439,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"scale": { "type": "number", "description": "Horizontal scale in percent." } }),
|
||||
&["doc", "page"],
|
||||
)),
|
||||
t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 },
|
||||
"script": { "type": "string", "enum": ["superscript", "subscript"] },
|
||||
"range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }),
|
||||
&["doc", "page", "paragraph", "script"],
|
||||
)),
|
||||
t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
|
||||
@@ -664,6 +664,44 @@ fn tool_table_is_well_formed() {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_homogeneous_enums(schema: &Value, path: &str) {
|
||||
fn json_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
let first = values.first().map(json_kind);
|
||||
assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}");
|
||||
}
|
||||
match schema {
|
||||
Value::Object(object) => {
|
||||
for (key, child) in object {
|
||||
assert_homogeneous_enums(child, &format!("{path}.{key}"));
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for (index, child) in array.iter().enumerate() {
|
||||
assert_homogeneous_enums(child, &format!("{path}[{index}]"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_enums_have_one_json_type() {
|
||||
for tool in tools() {
|
||||
assert_homogeneous_enums(&tool.input_schema, tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() {
|
||||
let dir = workdir("font-ranges");
|
||||
@@ -3286,6 +3324,39 @@ fn editing_existing_text_through_tools() {
|
||||
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
|
||||
}
|
||||
|
||||
/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space
|
||||
/// (which has no superscript form), each one undo step; refusals change nothing.
|
||||
#[test]
|
||||
fn superscript_and_subscript_through_tools() {
|
||||
let dir = workdir("text-script");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let paragraph =
|
||||
|a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string();
|
||||
let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range });
|
||||
// "Page 2": the 2 is byte 5.
|
||||
let r = ok(&mut a, "text_script", script("superscript", json!([5, 6])));
|
||||
assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}");
|
||||
assert_eq!(page_text(&mut a, doc)[1], "Page ²");
|
||||
// " ²": the space has no superscript form, and the range still toggles back.
|
||||
ok(&mut a, "text_script", script("superscript", json!([4, 7])));
|
||||
assert_eq!(paragraph(&mut a), "Page 2");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text");
|
||||
assert_eq!(paragraph(&mut a), "Page ²");
|
||||
// Refused, changing nothing: a range that splits ², one past the end, an empty one, a range
|
||||
// with nothing that has a subscript form, an unknown script.
|
||||
for (name, range) in [
|
||||
("superscript", json!([5, 6])),
|
||||
("superscript", json!([0, 99])),
|
||||
("superscript", json!([3, 3])),
|
||||
("subscript", json!([4, 5])),
|
||||
("italic", json!([0, 1])),
|
||||
] {
|
||||
assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}");
|
||||
}
|
||||
assert_eq!(paragraph(&mut a), "Page ²");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
@@ -3453,6 +3524,13 @@ fn editing_page_images_through_tools() {
|
||||
assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0]));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" }));
|
||||
// A single image is already in front and at the back; no-op arrange is refused without dirtying undo.
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })).is_err());
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" }));
|
||||
assert_eq!(r["undo"], "Remove crop");
|
||||
// Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn.
|
||||
let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 }));
|
||||
@@ -3472,6 +3550,80 @@ fn editing_page_images_through_tools() {
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arranging_page_images_through_tools() {
|
||||
let dir = workdir("arrange-page-images");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] }));
|
||||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_image", json!({ "doc": made, "page": 1, "path": "src/a_page_2.png", "rect": [50, 50, 100, 100] }));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 2);
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" }));
|
||||
assert_eq!(r["undo"], "Bring to front");
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 2, "action": "send_to_back" }));
|
||||
assert_eq!(r["undo"], "Send to back");
|
||||
ok(&mut a, "doc_save", json!({ "doc": made, "path": "arranged.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "arranged.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after_reopen = ok(&mut a, "page_images", json!({ "doc": reopened, "page": 1 }));
|
||||
assert_eq!(after_reopen["count"], 2);
|
||||
assert_eq!(after_reopen["images"][0]["rect"], json!([0.0, 0.0, 50.0, 75.0]));
|
||||
assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly,
|
||||
/// in the same top-left-origin points as the other image tools, also on a turned page.
|
||||
#[test]
|
||||
fn transforming_page_images_through_tools() {
|
||||
let dir = workdir("transform-page-images");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] }));
|
||||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
let transform =
|
||||
|a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] }));
|
||||
|
||||
// Right 10, down 20.
|
||||
assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image");
|
||||
assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0]));
|
||||
// Twice the size about the top-left corner of the page.
|
||||
transform(&mut a, json!([2, 0, 0, 2, 0, 0]));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
// A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step.
|
||||
transform(&mut a, json!([0, 1, -1, 0, 200, 0]));
|
||||
assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0]));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": made }));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
|
||||
// On a turned page "right" is still right on screen.
|
||||
ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 }));
|
||||
let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap());
|
||||
let [x0, y0, x1, y1] = numbers(rect(&mut a));
|
||||
transform(&mut a, json!([1, 0, 0, 1, 10, 0]));
|
||||
let after = numbers(rect(&mut a));
|
||||
for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) {
|
||||
assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10");
|
||||
}
|
||||
|
||||
// Flat, non-finite, short and missing matrices are refused before anything changes.
|
||||
let before = rect(&mut a);
|
||||
for bad in [
|
||||
json!([1, 2, 2, 4, 0, 0]),
|
||||
json!([0, 0, 0, 0, 0, 0]),
|
||||
json!([1e300, 0, 0, 1e300, 0, 0]),
|
||||
json!([1, 0, 0, 1]),
|
||||
json!([1, 0, "invalid", 0, 1, 10, 0]),
|
||||
json!([1, 0, null, 0, 1, 10, 0]),
|
||||
] {
|
||||
let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}");
|
||||
}
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(rect(&mut a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_image_through_tools() {
|
||||
let dir = workdir("enhance-camera");
|
||||
@@ -9764,7 +9916,11 @@ fn added_text_lines_take_their_own_alignments() {
|
||||
let dir = workdir("paragraph-aligns");
|
||||
let mut a = auto(&dir);
|
||||
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
|
||||
ok(
|
||||
&mut a,
|
||||
"page_add_text",
|
||||
json!({"doc":id,"page":1,"text":"Left line\nRight line\nInherited line","at":[40,50],"width":500,"size":12,"aligns":[null,"right","inherit"]}),
|
||||
);
|
||||
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
|
||||
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
|
||||
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
|
||||
@@ -9772,10 +9928,13 @@ fn added_text_lines_take_their_own_alignments() {
|
||||
};
|
||||
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
|
||||
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
|
||||
assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0);
|
||||
// The whole box centred: the right-aligned line goes with it.
|
||||
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
|
||||
let right = rect(&mut a, "Right line");
|
||||
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
|
||||
let inherited = rect(&mut a, "Inherited line");
|
||||
assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}");
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
|
||||
}
|
||||
@@ -10190,3 +10349,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() {
|
||||
let dir = workdir("editor-crop-group-arrange");
|
||||
let mut a = auto(&dir);
|
||||
let source = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({"doc":source,"folder":"src","dpi":18,"pages":[1]}));
|
||||
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["src/a_page_1.png"]}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[5,5,25,35]}));
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"crop","rect":[10,10,20,30]}));
|
||||
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Movable label","rect":[2,40,45,55],"size":6}));
|
||||
let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||||
let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect();
|
||||
assert_eq!(objects.len(), 2);
|
||||
let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"objects":objects,"offset":[3,4]}));
|
||||
assert_eq!(moved["moved"], 2);
|
||||
let picture = ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0].clone();
|
||||
assert_eq!(picture["rect"], json!([8.0, 9.0, 28.0, 39.0]));
|
||||
assert_eq!(picture["visible_rect"], json!([13.0, 14.0, 23.0, 34.0]));
|
||||
assert!(page_text(&mut a, doc)[0].contains("Movable label"));
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0]["visible_rect"], json!([10.0, 10.0, 20.0, 30.0]));
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"grouped.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"grouped.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc":reopened,"page":1}))["images"][0]["visible_rect"], picture["visible_rect"]);
|
||||
ok(&mut a, "image_edit", json!({"doc":reopened,"page":1,"image":1,"action":"bring_to_front"}));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 1);
|
||||
ok(&mut a, "content_delete", json!({"doc":reopened,"page":1,"index":1}));
|
||||
ok(&mut a, "doc_save", json!({"doc":reopened,"path":"independent.pdf"}));
|
||||
let saved = ok(&mut a, "doc_open", json!({"path":"independent.pdf"}))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "page_images", json!({"doc":saved,"page":1}));
|
||||
assert_eq!(after["count"], 1);
|
||||
assert_eq!(after["images"][0]["rect"], picture["rect"]);
|
||||
assert_eq!(after["images"][0]["visible_rect"], picture["visible_rect"]);
|
||||
let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() {
|
||||
fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] {
|
||||
let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap();
|
||||
let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix;
|
||||
[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
|
||||
.map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from))
|
||||
}
|
||||
fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec<u8>) {
|
||||
let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut bytes).unwrap();
|
||||
bytes.truncate(frame.buffer_size());
|
||||
if frame.color_type == png::ColorType::Rgb {
|
||||
bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect();
|
||||
} else {
|
||||
assert_eq!(frame.color_type, png::ColorType::Rgba);
|
||||
}
|
||||
(frame.width as usize, frame.height as usize, bytes)
|
||||
}
|
||||
let dir = workdir("displayed-image-flip");
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]];
|
||||
let pixels: Vec<u8> = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect();
|
||||
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut failures = Vec::new();
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap();
|
||||
if rotation != 0 {
|
||||
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation}));
|
||||
}
|
||||
let before = corners(&a, doc);
|
||||
let (width, height, pixels) = raster(&mut a, doc);
|
||||
let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
|
||||
let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
|
||||
let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
|
||||
let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
|
||||
for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] {
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action}));
|
||||
let after = corners(&a, doc);
|
||||
for (old, new) in before.into_iter().zip(after) {
|
||||
let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] };
|
||||
if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) {
|
||||
failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (new_width, new_height, actual_pixels) = raster(&mut a, doc);
|
||||
assert_eq!((new_width, new_height), (width, height));
|
||||
let mut expected_pixels = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 };
|
||||
expected_pixels.extend_from_slice(&pixels[source..source + 4]);
|
||||
}
|
||||
}
|
||||
if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) {
|
||||
failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis"));
|
||||
}
|
||||
assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(corners(&a, doc), before, "one undo restores the original placement");
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() {
|
||||
let dir = workdir("mixed-script-policy");
|
||||
let mut a = auto(&dir);
|
||||
for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] {
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160}));
|
||||
let paragraph =
|
||||
|a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned();
|
||||
assert_eq!(paragraph(&mut a), mixed);
|
||||
let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||||
let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script});
|
||||
let once = ok(&mut a, "text_script", args.clone());
|
||||
assert_eq!(once["paragraph"]["text"], uniform);
|
||||
assert_eq!(paragraph(&mut a), uniform);
|
||||
ok(&mut a, "text_script", args);
|
||||
assert_eq!(paragraph(&mut a), plain);
|
||||
assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture");
|
||||
assert_eq!(
|
||||
a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(),
|
||||
before.as_ref(),
|
||||
"Undo also restores the exact prior document bytes"
|
||||
);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), uniform);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), plain);
|
||||
let path = format!("mixed-{script}.pdf");
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":path}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page_text(&mut a, reopened)[0], plain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
if let Some(p) = prev {
|
||||
trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
|
||||
}
|
||||
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
|
||||
let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32));
|
||||
if as_stream {
|
||||
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
|
||||
write_xref_stream(&mut out, base, &mut trailer, rows, size);
|
||||
|
||||
@@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
assert_qpdf_clean(&saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_save_ignores_a_negative_trailer_size() {
|
||||
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
|
||||
doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1));
|
||||
let next = doc.add(Object::Int(5)).num + 1;
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let back = Document::open(Arc::new(saved)).unwrap();
|
||||
assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next)));
|
||||
}
|
||||
|
||||
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
|
||||
/// not written: the file never held it, and writing it would keep what was removed. Additions the
|
||||
/// update still refers to, from the trailer or a changed object and through other additions, are.
|
||||
|
||||
@@ -236,11 +236,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
|
||||
let key = if mask { None } else { colour_key(doc, s) };
|
||||
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
|
||||
let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite());
|
||||
let inverted = (jpeg || mask)
|
||||
&& s.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
// Both ends have to be finite numbers, as for lossless images, or the default mapping stands.
|
||||
.and_then(|d| {
|
||||
let a = d.as_array()?;
|
||||
Some(endpoint(a.first())? > endpoint(a.get(1))?)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
|
||||
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
|
||||
@@ -612,7 +617,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
return Some(px);
|
||||
}
|
||||
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
|
||||
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
|
||||
Some(scaled(&img, w as u32, h as u32).into_raw())
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
|
||||
/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any
|
||||
/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process.
|
||||
/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a
|
||||
/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was.
|
||||
pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage {
|
||||
use image::imageops::{FilterType, resize};
|
||||
let (mw, mh) = (u64::from(img.width()), u64::from(img.height()));
|
||||
let largest = (mw * mh).max(u64::from(w) * u64::from(h));
|
||||
if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
|
||||
resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle)
|
||||
} else {
|
||||
resize(img, w, h, FilterType::Triangle)
|
||||
}
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
|
||||
|
||||
@@ -2209,3 +2209,115 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
|
||||
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read
|
||||
/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws.
|
||||
#[test]
|
||||
fn a_stencil_masks_decode_array_can_hold_references() {
|
||||
use pdfcraft_cos::Document;
|
||||
let export = |decode: &str| {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\
|
||||
5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
);
|
||||
let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap();
|
||||
let mut export = crate::extract::extract_images(&doc, &[0], 1);
|
||||
assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped);
|
||||
export.images.remove(0).data
|
||||
};
|
||||
let (inverted, identity) = (export("[1 0]"), export("[0 1]"));
|
||||
assert_ne!(inverted, identity, "the two ways round differ");
|
||||
for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] {
|
||||
assert_eq!(export(decode), inverted, "{decode}");
|
||||
}
|
||||
for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] {
|
||||
assert_eq!(export(decode), identity, "{decode}");
|
||||
}
|
||||
// A reference that only leads back to itself holds no number, and an end that is missing or isn't
|
||||
// a number leaves the default mapping, whichever end it is.
|
||||
for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] {
|
||||
assert_eq!(export(decode), identity, "{decode}");
|
||||
}
|
||||
// Equal ends aren't the two ways round either, and a number too long to hold reads as infinite.
|
||||
assert_eq!(export("[1 1]"), identity);
|
||||
assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity);
|
||||
}
|
||||
|
||||
/// A mask of other proportions than its image is scaled to it, however far apart they are: the
|
||||
/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the
|
||||
/// process. The third would ask for 10 GB if it went across first, which is why the way round
|
||||
/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the
|
||||
/// image's size and larger than it, are scaled as they always were.
|
||||
#[test]
|
||||
fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
let gray = |w: i64, h: i64, data: &[u8]| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(w));
|
||||
d.set(b"Height".to_vec(), Object::Int(h));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
Stream::from_raw(d, pdfcraft_filters::encode_flate(data))
|
||||
};
|
||||
let shapes = [
|
||||
(100_000usize, 1usize, 1usize, 100_000usize),
|
||||
(100_000, 64, 64, 100_000),
|
||||
(1, 100_000, 100_000, 1),
|
||||
(64, 1, 1, 64),
|
||||
(3, 2, 6, 4),
|
||||
(3, 2, 3, 2),
|
||||
(6, 4, 3, 2),
|
||||
];
|
||||
for (mask_w, mask_h, w, h) in shapes {
|
||||
let mut doc = Document::new_empty();
|
||||
let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h])));
|
||||
let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]);
|
||||
image.dict.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
let image = doc.add(Object::Stream(image));
|
||||
let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}"));
|
||||
assert_eq!(name, "png");
|
||||
let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap();
|
||||
let mut rgba = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut rgba).unwrap();
|
||||
assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba));
|
||||
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The long way round is for proportions far apart: up to a quarter over the larger image the
|
||||
/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes
|
||||
/// out the same either way.
|
||||
#[test]
|
||||
fn proportions_up_to_the_limit_scale_as_they_did() {
|
||||
let noise = |w: u32, h: u32| {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let px = (0..w * h)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect();
|
||||
image::GrayImage::from_raw(w, h, px).unwrap()
|
||||
};
|
||||
// Each exactly a quarter over the larger: the source's size, then the target's.
|
||||
for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
|
||||
let mask = noise(mw, mh);
|
||||
let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle);
|
||||
assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}");
|
||||
}
|
||||
// A step past it goes the long way, which is how to tell.
|
||||
let mask = noise(25, 15);
|
||||
let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle);
|
||||
assert_ne!(crate::extract::scaled(&mask, 10, 20), direct);
|
||||
// A mask of its image's size comes through as it is.
|
||||
let mask = noise(20, 10);
|
||||
assert_eq!(crate::extract::scaled(&mask, 20, 10), mask);
|
||||
}
|
||||
|
||||
@@ -24,3 +24,5 @@ unicode-bidi = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
+1313
-142
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ pub type Rgb = [f64; 3];
|
||||
|
||||
pub mod backdrop;
|
||||
pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
|
||||
pub use text::strip_subset_tag;
|
||||
mod background;
|
||||
pub use background::page_background;
|
||||
pub mod objects;
|
||||
@@ -43,7 +44,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
|
||||
mod flatten;
|
||||
pub use flatten::{flatten, flatten_fill_sign};
|
||||
pub mod images;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
|
||||
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
mod linebreak;
|
||||
|
||||
+1728
-1
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ use crate::EditError;
|
||||
mod regions;
|
||||
#[path = "text_session.rs"]
|
||||
mod session;
|
||||
pub use fallback::strip_subset_tag;
|
||||
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
|
||||
pub use session::TextSession;
|
||||
|
||||
@@ -47,7 +48,7 @@ pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_l
|
||||
pub(crate) use fallback::SOURCE_SPACE_CALLS;
|
||||
use fallback::{
|
||||
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
|
||||
source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use fallback::{add_type3_font, is_type3_fallback};
|
||||
|
||||
@@ -163,7 +163,7 @@ pub(crate) fn same_base_font(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged.
|
||||
pub(crate) fn strip_subset_tag(name: &str) -> &str {
|
||||
pub fn strip_subset_tag(name: &str) -> &str {
|
||||
let bytes = name.as_bytes();
|
||||
let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase));
|
||||
if is_tag { name.get(7..).unwrap_or(name) } else { name }
|
||||
|
||||
@@ -19,6 +19,7 @@ pdfcraft-annot = { workspace = true }
|
||||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-redact = { workspace = true }
|
||||
pdfcraft-print = { workspace = true }
|
||||
|
||||
@@ -18,9 +18,8 @@ impl Document {
|
||||
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
|
||||
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
|
||||
self.page_images(page).iter().rposition(|i| {
|
||||
inside(&i.rect)
|
||||
i.contains(at)
|
||||
&& match (i.is_form, i.object) {
|
||||
(false, Some(_)) => true,
|
||||
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
|
||||
/// A change to an existing page image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -20,6 +20,12 @@ pub enum ImageEdit {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
Delete,
|
||||
/// Affine transform [a, b, c, d, e, f] applied to the image's matrix.
|
||||
Transform([f64; 6]),
|
||||
/// Change the stacking order.
|
||||
Arrange(ArrangeOrder),
|
||||
/// Remove any private crop on the image.
|
||||
RemoveCrop,
|
||||
}
|
||||
|
||||
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
|
||||
|
||||
@@ -111,10 +111,17 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Move object",
|
||||
"Move objects",
|
||||
"Move image",
|
||||
"Transform image",
|
||||
"Rotate image",
|
||||
"Crop image",
|
||||
"Flip image",
|
||||
"Replace image",
|
||||
"Delete image",
|
||||
"Bring to front",
|
||||
"Bring forward",
|
||||
"Send backward",
|
||||
"Send to back",
|
||||
"Remove crop",
|
||||
"Add header & footer",
|
||||
"Update header & footer",
|
||||
"Add watermark",
|
||||
@@ -254,11 +261,19 @@ impl Edit {
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
ImageEdit::Move(_) => "Move image",
|
||||
ImageEdit::Transform(_) => "Transform image",
|
||||
ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image",
|
||||
ImageEdit::Crop(_) => "Crop image",
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
ImageEdit::Arrange(ord) => match ord {
|
||||
pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front",
|
||||
pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward",
|
||||
pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward",
|
||||
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
|
||||
},
|
||||
ImageEdit::RemoveCrop => "Remove crop",
|
||||
}),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
|
||||
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
|
||||
|
||||
+165
-34
@@ -4,6 +4,7 @@ use super::{
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
use pdfcraft_content::Matrix;
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
@@ -250,6 +251,169 @@ impl EditCtx {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> {
|
||||
match change {
|
||||
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
|
||||
}
|
||||
ImageEdit::Transform(t) => {
|
||||
let [a, b, c, d, _, _] = *t;
|
||||
let determinant = a * d - b * c;
|
||||
if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into())));
|
||||
}
|
||||
}
|
||||
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let img = pdfcraft_edit::page_images(doc, page)?
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into())));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
|
||||
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
|
||||
};
|
||||
let page_model = pdfcraft_model::pages(doc)
|
||||
.into_iter()
|
||||
.nth(page)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
|
||||
let target_stream_obj = match page_model.dict.get(b"Contents") {
|
||||
None => None,
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
|
||||
_ if img.stream == 0 => c.as_ref(),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
|
||||
// Ownership belongs to the drawing stream, not the XObject: original and added images may
|
||||
// show the same resource without sharing an editable item.
|
||||
let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t));
|
||||
let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a)));
|
||||
|
||||
let mut ai_to_update = None;
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
|| non_axis_transform
|
||||
|| matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete);
|
||||
|
||||
if should_promote {
|
||||
doc.update_dict(added_item.obj, |d| {
|
||||
d.remove(b"PCAdded");
|
||||
d.remove(b"PCMark");
|
||||
})
|
||||
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
|
||||
} else {
|
||||
ai_to_update = Some((*added_idx, ai));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((added_idx, mut ai)) = ai_to_update {
|
||||
let view = Matrix(page_model.view_matrix(doc));
|
||||
let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?;
|
||||
|
||||
match change {
|
||||
ImageEdit::Move(to) => {
|
||||
ai.rect = to_display.bbox(*to);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Transform(t) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
|
||||
};
|
||||
let t_display = view.then(&Matrix(t)).then(&to_display);
|
||||
let [ta, tb, tc, td, _, _] = t_display.0;
|
||||
let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5;
|
||||
let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5;
|
||||
|
||||
let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel {
|
||||
(0, ta < 0.0, td < 0.0)
|
||||
} else if is_perpendicular {
|
||||
match (tb > 0.0, tc > 0.0) {
|
||||
(true, false) => (1, false, false),
|
||||
(false, true) => (3, false, false),
|
||||
(true, true) => (1, false, true),
|
||||
(false, false) => (1, true, false),
|
||||
}
|
||||
} else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"non-axis-aligned transforms are not supported for added images".into(),
|
||||
)));
|
||||
};
|
||||
let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) };
|
||||
ai.flip_h ^= toggle_h;
|
||||
ai.flip_v ^= toggle_v;
|
||||
ai.rotation = (ai.rotation + quarters_ccw) % 4;
|
||||
ai.rect = t_display.bbox(ai.rect);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Rotate(q) => {
|
||||
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
|
||||
ai.rotation = (ai.rotation + k) % 4;
|
||||
let r = ai.rect;
|
||||
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
if k % 2 == 1 {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
|
||||
}
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Replace { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
|
||||
};
|
||||
ai.image = new_image;
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform an edit on a working copy (the caller discards it on error).
|
||||
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
match edit {
|
||||
@@ -525,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::AddOcrText { page, words } => {
|
||||
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => {
|
||||
let img = pdfcraft_edit::page_images(doc, *page)?
|
||||
.into_iter()
|
||||
.nth(*index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"grouped Form artwork cannot be replaced with a bitmap".into(),
|
||||
)));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
|
||||
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
|
||||
@@ -54,6 +54,7 @@ mod stamp_preview;
|
||||
mod test_hooks;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod text_script;
|
||||
pub mod text_style_bookmarks;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
@@ -64,6 +65,7 @@ use std::sync::Arc;
|
||||
pub use links::{
|
||||
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
pub use pdfcraft_edit::strip_subset_tag;
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::{CommentLayer, StampPreview};
|
||||
@@ -137,7 +139,7 @@ pub use history::Label;
|
||||
use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
|
||||
pub use open::{OpenContext, PreparedDocument};
|
||||
pub use page_filter::{PageOrientation, PageParity, filter_pages};
|
||||
pub use pictures::MarkFile;
|
||||
pub use pictures::{MarkFile, image_dimensions};
|
||||
use pictures::{embed_image, fit_picture, mark_source};
|
||||
use protection::unrestricted;
|
||||
pub use protection::{Changes, Printing, Protection, SecuritySummary};
|
||||
@@ -154,6 +156,8 @@ use xfa_form::{
|
||||
mod clock;
|
||||
mod doc_state;
|
||||
|
||||
pub use pdfcraft_edit::ArrangeOrder;
|
||||
|
||||
use clock::{local_utc_offset, parse_ymd};
|
||||
pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files};
|
||||
use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::ImageDecoder;
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
|
||||
@@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
|
||||
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let format = image::guess_format(bytes).ok()?;
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(64_000_000 * 4);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder().ok()?;
|
||||
let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
if format == image::ImageFormat::Jpeg
|
||||
&& u64::from(w) * u64::from(h) <= (64 << 20)
|
||||
&& let Ok(orientation) = decoder.orientation()
|
||||
&& matches!(
|
||||
orientation,
|
||||
image::metadata::Orientation::Rotate90
|
||||
| image::metadata::Orientation::Rotate270
|
||||
| image::metadata::Orientation::Rotate90FlipH
|
||||
| image::metadata::Orientation::Rotate270FlipH
|
||||
)
|
||||
{
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg");
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() {
|
||||
let turned_6 = phone_jpeg(60, 20, 6);
|
||||
assert_eq!(image_dimensions(&turned_6), Some((20, 60)));
|
||||
|
||||
let turned_8 = phone_jpeg(60, 20, 8);
|
||||
assert_eq!(image_dimensions(&turned_8), Some((20, 60)));
|
||||
|
||||
let upright_1 = phone_jpeg(60, 20, 1);
|
||||
assert_eq!(image_dimensions(&upright_1), Some((60, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_return_none() {
|
||||
assert_eq!(image_dimensions(&[]), None);
|
||||
assert_eq!(image_dimensions(b"not an image"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,7 @@ impl Session {
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
});
|
||||
let touches_marks = edit.is_none_or(|e| {
|
||||
|
||||
+558
-15
@@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec());
|
||||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||||
let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>");
|
||||
if rotate != 0 {
|
||||
page_dict.push_str(&format!(" /Rotate {rotate}"));
|
||||
}
|
||||
if (user_unit - 1.0).abs() > 1e-6 {
|
||||
page_dict.push_str(&format!(" /UserUnit {user_unit}"));
|
||||
}
|
||||
page_dict.push_str(" >>");
|
||||
objs.push(page_dict.into_bytes());
|
||||
let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
|
||||
|
||||
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", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
fn session_with(n: usize) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
|
||||
@@ -6159,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
|
||||
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
|
||||
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
|
||||
/// times the fields now takes at most about eight times as long (it took about fifty).
|
||||
///
|
||||
/// Each round opens the small form and then the large one, so a busy spell on a shared machine
|
||||
/// slows both, and the best round's ratio is checked. Timing every small opening before the
|
||||
/// large ones let such a spell fall on the large ones alone.
|
||||
#[test]
|
||||
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
|
||||
let time = |n: usize| {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_default()
|
||||
let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100));
|
||||
let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800));
|
||||
let time = |bytes: &Arc<Vec<u8>>| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
};
|
||||
let small = time(100);
|
||||
let large = time(800);
|
||||
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
|
||||
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
|
||||
// The first openings also pay for what the process sets up once.
|
||||
time(&small);
|
||||
time(&large);
|
||||
let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect();
|
||||
let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6);
|
||||
let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min);
|
||||
assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)");
|
||||
}
|
||||
|
||||
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
|
||||
@@ -8102,6 +8138,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(rgba, px.into_raw());
|
||||
assert!(d.page_image_rgba(0, 1).is_err());
|
||||
|
||||
// When the image is cropped, page_image_at only hit-tests the visible cropped region:
|
||||
let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
|
||||
assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive");
|
||||
}
|
||||
|
||||
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
|
||||
@@ -9119,6 +9162,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
|
||||
assert_eq!(truncated, [0]);
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Transforming an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_transform_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
// Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap();
|
||||
let transformed = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(transformed), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating by degrees, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_by_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Arranging an added image moves it into the document stream and converts it to an ordinary
|
||||
/// page image (dropping the PCAdded marker), so no empty shell remains in list_added.
|
||||
#[test]
|
||||
fn added_image_arrange_converts_to_ordinary_image() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap();
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 2);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_crop_then_move_keeps_crop() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap();
|
||||
let img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_move_and_transform_on_rotated_and_user_unit_pages() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let png_bytes = Arc::new(png);
|
||||
|
||||
let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)];
|
||||
|
||||
for (rotate, user_unit) in cases {
|
||||
let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens");
|
||||
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap();
|
||||
|
||||
// Target user-space rectangle R
|
||||
let r = [30.0, 40.0, 80.0, 90.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap();
|
||||
|
||||
let painted = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted[i] - r[i]).abs() < 1e-3,
|
||||
"Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted[i],
|
||||
r[i]
|
||||
);
|
||||
}
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let stream_obj = match &*cos.get(added[0].obj) {
|
||||
pdfcraft_cos::Object::Stream(s) => s.clone(),
|
||||
other => panic!("expected stream, got {other:?}"),
|
||||
};
|
||||
let pc_rect = stream_obj
|
||||
.dict
|
||||
.get(b"PCAdded")
|
||||
.and_then(|o| cos.resolve(o).as_dict().cloned())
|
||||
.and_then(|d| d.get(b"Rect").cloned())
|
||||
.and_then(|o| cos.resolve(&o).as_array().cloned())
|
||||
.unwrap();
|
||||
let pc_box: [f64; 4] =
|
||||
[pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()];
|
||||
let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap();
|
||||
let re_idx = content.find(" re W n").expect("re W n in content stream");
|
||||
let prefix = &content[..re_idx];
|
||||
let nums: Vec<f64> =
|
||||
prefix.split_whitespace().rev().take(4).collect::<Vec<_>>().into_iter().rev().map(|val| val.parse::<f64>().unwrap()).collect();
|
||||
assert_eq!(nums.len(), 4);
|
||||
let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]);
|
||||
assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]);
|
||||
assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]);
|
||||
assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]);
|
||||
assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]);
|
||||
|
||||
// Transform test: user-space translation by (10, 10)
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap();
|
||||
|
||||
let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0];
|
||||
let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted_transformed[i] - expected_transformed[i]).abs() < 1e-3,
|
||||
"Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted_transformed[i],
|
||||
expected_transformed[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_on_added_image_replaces_xobject_and_undo_restores() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
let png2 = make_png([0, 0, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap();
|
||||
|
||||
let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref");
|
||||
assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject");
|
||||
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref");
|
||||
assert_eq!(restored_obj, old_obj, "undo restores the old XObject");
|
||||
|
||||
// Also test replacement on an image with active crop (promoted path)
|
||||
let png3 = make_png([0, 255, 0]);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap();
|
||||
let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref");
|
||||
assert_eq!(crop_obj, old_obj);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap();
|
||||
let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref");
|
||||
assert_ne!(promoted_new_obj, crop_obj);
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let initial_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]);
|
||||
assert!(initial_img.matrix[0] > 0.0, "initially unflipped");
|
||||
|
||||
// Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
|
||||
let flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix);
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert!(ai.flip_h, "PCAdded record must have flip_h = true");
|
||||
|
||||
// Mirroring horizontally again flips it back
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis");
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert!(!ai2.flip_h, "PCAdded record must have flip_h = false");
|
||||
|
||||
// Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap();
|
||||
let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix);
|
||||
let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") };
|
||||
assert!(ai3.flip_v, "PCAdded record must have flip_v = true");
|
||||
|
||||
// Non-finite transform must be refused with error
|
||||
let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) });
|
||||
assert!(res.is_err(), "NaN transform must be refused");
|
||||
|
||||
let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) });
|
||||
assert!(res_inf.is_err(), "infinite transform must be refused");
|
||||
|
||||
let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) });
|
||||
assert!(res_move.is_err(), "NaN position must be refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([0, 128, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
// 1. A pure 90° clockwise rotation matrix about the image centre:
|
||||
let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap();
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1, "stays as PCAdded image");
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)");
|
||||
// Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175]
|
||||
assert!((ai.rect[0] - 125.0).abs() < 1e-4);
|
||||
assert!((ai.rect[1] - 75.0).abs() < 1e-4);
|
||||
assert!((ai.rect[2] - 175.0).abs() < 1e-4);
|
||||
assert!((ai.rect[3] - 175.0).abs() < 1e-4);
|
||||
|
||||
// 2. A pure 90° counter-clockwise rotation matrix:
|
||||
let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap();
|
||||
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0");
|
||||
assert!((ai2.rect[0] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[1] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[2] - 200.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[3] - 150.0).abs() < 1e-4);
|
||||
|
||||
// Arbitrary transforms promote the added image, just like RotateBy.
|
||||
let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image");
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0;
|
||||
let actual = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}");
|
||||
s.undo(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability");
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_added_image_transform_reflections_with_rotation() {
|
||||
let make_asymmetric_png = || {
|
||||
let mut px = Vec::with_capacity(20 * 20 * 3);
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let color = match (x < 10, y < 10) {
|
||||
(true, true) => [255, 0, 0], // TL: Red
|
||||
(false, true) => [0, 255, 0], // TR: Green
|
||||
(true, false) => [0, 0, 255], // BL: Blue
|
||||
(false, false) => [255, 255, 0], // BR: Yellow
|
||||
};
|
||||
px.extend_from_slice(&color);
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 20, 20);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
|
||||
let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
let i = ((y_from_top * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
};
|
||||
|
||||
let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50;
|
||||
let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50;
|
||||
let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200;
|
||||
let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50;
|
||||
|
||||
let check_quadrants =
|
||||
|s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| {
|
||||
let c_tl = colour_at(s, id, 75, 125);
|
||||
let c_tr = colour_at(s, id, 125, 125);
|
||||
let c_bl = colour_at(s, id, 75, 175);
|
||||
let c_br = colour_at(s, id, 125, 175);
|
||||
assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl);
|
||||
assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr);
|
||||
assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl);
|
||||
assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br);
|
||||
};
|
||||
|
||||
// --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green.
|
||||
{
|
||||
// 1a: Flip Horizontal after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 1b: Flip Vertical after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical");
|
||||
}
|
||||
|
||||
// --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue.
|
||||
{
|
||||
// 2a: Flip Horizontal after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 2b: Flip Vertical after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical");
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
|
||||
/// field the file leaves to the viewer. The page as displayed and printed as an image shows the
|
||||
/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in
|
||||
@@ -9469,3 +9980,35 @@ fn saving_without_deleting_pages_stays_incremental() {
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() {
|
||||
let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap();
|
||||
let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone();
|
||||
let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap());
|
||||
pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let mut session = Session::new();
|
||||
let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let before_added = session.get(id).unwrap().added()[0].content.clone();
|
||||
session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata");
|
||||
assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_transform_engine_refuses_singular_matrices_without_mutation() {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut png, 1, 1);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap();
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap();
|
||||
let id = s.open("image.pdf", None, bytes, None).unwrap();
|
||||
for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] {
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err());
|
||||
assert_eq!(s.get(id).unwrap().bytes, before);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-10
@@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// Bounded scan with resolved roots and no descendant symlinks.
|
||||
/// Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// The caller controls roots (automation resolves confinement before calling this).
|
||||
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
discover_budgeted(roots, 64u64 << 20)
|
||||
}
|
||||
|
||||
/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total
|
||||
/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate)
|
||||
/// does not spend it. Every file read
|
||||
/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan.
|
||||
pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec<FontFile> {
|
||||
use std::io::Read;
|
||||
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
|
||||
// User font directories themselves are often links. Resolve only the explicitly
|
||||
// supplied roots; links found below them must not escape an automation read root.
|
||||
let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect();
|
||||
let mut files = Vec::new();
|
||||
let mut entries = 0usize;
|
||||
let mut budget = 64u64 << 20;
|
||||
let mut read_allowance = budget.saturating_mul(4);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen_face_content = std::collections::HashSet::new();
|
||||
while let Some((path, depth)) = pending.pop() {
|
||||
if entries >= 4096 || budget == 0 {
|
||||
if entries >= 4096 || budget == 0 || read_allowance == 0 {
|
||||
break;
|
||||
}
|
||||
entries += 1;
|
||||
@@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
if meta.is_dir() && depth < 4 {
|
||||
if let Ok(children) = std::fs::read_dir(&path) {
|
||||
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
|
||||
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
|
||||
// Best effort: unreadable entries are skipped and do not count against the entry allowance.
|
||||
let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect();
|
||||
sorted_children.sort();
|
||||
pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1)));
|
||||
}
|
||||
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
|
||||
if meta.len() > budget {
|
||||
if meta.len() > budget || meta.len() > read_allowance {
|
||||
continue;
|
||||
}
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
read_allowance = read_allowance.saturating_sub(meta.len());
|
||||
let mut bytes = Vec::new();
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
|
||||
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes));
|
||||
if read.is_err() || bytes.len() != meta.len() as usize {
|
||||
continue;
|
||||
}
|
||||
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
|
||||
files.push(FontFile { path, family, face });
|
||||
let key = font_content_key(&bytes);
|
||||
if seen_face_content.insert((face.clone(), key)) {
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
files.push(FontFile { path, family, face });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
|
||||
files
|
||||
}
|
||||
|
||||
fn font_content_key(bytes: &[u8]) -> (usize, u64) {
|
||||
use std::hash::{DefaultHasher, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write(bytes);
|
||||
(bytes.len(), h.finish())
|
||||
}
|
||||
fn font_extension(path: &Path) -> bool {
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_linked_font_root_is_scanned_but_descendant_links_are_not() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id()));
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
std::fs::copy(&source, real.join("regular.ttf")).unwrap();
|
||||
let link = root.join("linked");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap();
|
||||
std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap();
|
||||
let found = discover(&[link]);
|
||||
let expected = real.canonicalize().unwrap().join("regular.ttf");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found.first().unwrap().face, "Inter-Regular");
|
||||
assert_eq!(found.first().unwrap().path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_keeps_all_valid_faces_within_its_scan_budget() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let bytes = std::fs::read(source).unwrap();
|
||||
for id in 0..501u32 {
|
||||
let mut font = bytes.clone();
|
||||
// Valid standalone fonts with distinct bytes: an unreferenced trailer
|
||||
// does not change their tables or embedding licence.
|
||||
font.extend_from_slice(&id.to_le_bytes());
|
||||
std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap();
|
||||
}
|
||||
let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installed_directories() {
|
||||
let dirs = installed_directories();
|
||||
assert!(!dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_extension() {
|
||||
assert!(font_extension(Path::new("Arial.ttf")));
|
||||
assert!(font_extension(Path::new("Noto.otf")));
|
||||
assert!(font_extension(Path::new("CUSTOM.TTF")));
|
||||
assert!(font_extension(Path::new("ROBOTO.OTF")));
|
||||
assert!(!font_extension(Path::new("document.pdf")));
|
||||
assert!(!font_extension(Path::new("font.woff")));
|
||||
assert!(!font_extension(Path::new("font.woff2")));
|
||||
assert!(!font_extension(Path::new("no_extension")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_content_key() {
|
||||
let empty = font_content_key(&[]);
|
||||
assert_eq!(empty.0, 0);
|
||||
|
||||
let a = font_content_key(b"TrueType font sample data bytes");
|
||||
let b = font_content_key(b"TrueType font sample data bytes");
|
||||
let c = font_content_key(b"Different font sample data bytes");
|
||||
|
||||
assert_eq!(a, b, "identical bytes must produce identical keys");
|
||||
assert_ne!(a, c, "differing bytes must produce distinct keys");
|
||||
assert_eq!(a.0, b"TrueType font sample data bytes".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_empty_and_nonexistent_roots() {
|
||||
let empty = discover(&[]);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
|
||||
assert!(nonexistent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_deduplicates_non_adjacent_same_content() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
|
||||
let dir_a = root.join("a");
|
||||
let dir_b = root.join("b");
|
||||
let dir_c = root.join("c");
|
||||
let _ = std::fs::create_dir_all(&dir_a);
|
||||
let _ = std::fs::create_dir_all(&dir_b);
|
||||
let _ = std::fs::create_dir_all(&dir_c);
|
||||
|
||||
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
|
||||
|
||||
if inter_reg.exists() && inter_med.exists() {
|
||||
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
|
||||
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
|
||||
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
|
||||
|
||||
let discovered = discover(std::slice::from_ref(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
|
||||
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
|
||||
assert!(faces.contains(&"Inter-Regular"));
|
||||
assert!(faces.contains(&"Inter-Medium"));
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
||||
let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len();
|
||||
let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len();
|
||||
|
||||
// An unusable candidate, scanned first (sorted before the real fonts).
|
||||
std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap();
|
||||
std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap();
|
||||
|
||||
// The budget fits Inter-Regular alone; the rejected file must not use it up first.
|
||||
let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"], "a rejected file spends no budget");
|
||||
assert_eq!(faces(&run1), faces(&run2));
|
||||
|
||||
// Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular.
|
||||
std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap();
|
||||
let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
assert_eq!(one.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"]);
|
||||
let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len);
|
||||
assert_eq!(both.len(), 2);
|
||||
|
||||
// Reading stops at four times the budget, whatever was rejected.
|
||||
for i in 0..4 {
|
||||
std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap();
|
||||
}
|
||||
let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and
|
||||
//! `2` ↔ `₂`.
|
||||
//!
|
||||
//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes
|
||||
//! for raised and lowered text, so the font must have those glyphs, and extracted text contains
|
||||
//! them. Characters Unicode has no such form for are left as they are.
|
||||
//!
|
||||
//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`,
|
||||
//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles
|
||||
//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original
|
||||
//! mixture. Restoring that exact prior text requires the caller's edit history (Undo).
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// Raised or lowered text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Each character Unicode raises, and its superscript form.
|
||||
const SUPERSCRIPT: &[(char, char)] = &[
|
||||
('0', '⁰'),
|
||||
('1', '¹'),
|
||||
('2', '²'),
|
||||
('3', '³'),
|
||||
('4', '⁴'),
|
||||
('5', '⁵'),
|
||||
('6', '⁶'),
|
||||
('7', '⁷'),
|
||||
('8', '⁸'),
|
||||
('9', '⁹'),
|
||||
('+', '⁺'),
|
||||
('−', '⁻'),
|
||||
('=', '⁼'),
|
||||
('(', '⁽'),
|
||||
(')', '⁾'),
|
||||
('a', 'ᵃ'),
|
||||
('b', 'ᵇ'),
|
||||
('c', 'ᶜ'),
|
||||
('d', 'ᵈ'),
|
||||
('e', 'ᵉ'),
|
||||
('f', 'ᶠ'),
|
||||
('g', 'ᵍ'),
|
||||
('h', 'ʰ'),
|
||||
('i', 'ⁱ'),
|
||||
('j', 'ʲ'),
|
||||
('k', 'ᵏ'),
|
||||
('l', 'ˡ'),
|
||||
('m', 'ᵐ'),
|
||||
('n', 'ⁿ'),
|
||||
('o', 'ᵒ'),
|
||||
('p', 'ᵖ'),
|
||||
('r', 'ʳ'),
|
||||
('s', 'ˢ'),
|
||||
('t', 'ᵗ'),
|
||||
('u', 'ᵘ'),
|
||||
('v', 'ᵛ'),
|
||||
('w', 'ʷ'),
|
||||
('x', 'ˣ'),
|
||||
('y', 'ʸ'),
|
||||
('z', 'ᶻ'),
|
||||
('A', 'ᴬ'),
|
||||
('B', 'ᴮ'),
|
||||
('D', 'ᴰ'),
|
||||
('E', 'ᴱ'),
|
||||
('G', 'ᴳ'),
|
||||
('H', 'ᴴ'),
|
||||
('I', 'ᴵ'),
|
||||
('J', 'ᴶ'),
|
||||
('K', 'ᴷ'),
|
||||
('L', 'ᴸ'),
|
||||
('M', 'ᴹ'),
|
||||
('N', 'ᴺ'),
|
||||
('O', 'ᴼ'),
|
||||
('P', 'ᴾ'),
|
||||
('R', 'ᴿ'),
|
||||
('T', 'ᵀ'),
|
||||
('U', 'ᵁ'),
|
||||
('V', 'ⱽ'),
|
||||
('W', 'ᵂ'),
|
||||
];
|
||||
|
||||
/// Each character Unicode lowers, and its subscript form.
|
||||
const SUBSCRIPT: &[(char, char)] = &[
|
||||
('0', '₀'),
|
||||
('1', '₁'),
|
||||
('2', '₂'),
|
||||
('3', '₃'),
|
||||
('4', '₄'),
|
||||
('5', '₅'),
|
||||
('6', '₆'),
|
||||
('7', '₇'),
|
||||
('8', '₈'),
|
||||
('9', '₉'),
|
||||
('+', '₊'),
|
||||
('−', '₋'),
|
||||
('=', '₌'),
|
||||
('(', '₍'),
|
||||
(')', '₎'),
|
||||
('a', 'ₐ'),
|
||||
('e', 'ₑ'),
|
||||
('h', 'ₕ'),
|
||||
('i', 'ᵢ'),
|
||||
('j', 'ⱼ'),
|
||||
('k', 'ₖ'),
|
||||
('l', 'ₗ'),
|
||||
('m', 'ₘ'),
|
||||
('n', 'ₙ'),
|
||||
('o', 'ₒ'),
|
||||
('p', 'ₚ'),
|
||||
('r', 'ᵣ'),
|
||||
('s', 'ₛ'),
|
||||
('t', 'ₜ'),
|
||||
('u', 'ᵤ'),
|
||||
('v', 'ᵥ'),
|
||||
('x', 'ₓ'),
|
||||
];
|
||||
|
||||
impl Script {
|
||||
fn pairs(self) -> &'static [(char, char)] {
|
||||
match self {
|
||||
Script::Super => SUPERSCRIPT,
|
||||
Script::Sub => SUBSCRIPT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it.
|
||||
/// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision.
|
||||
pub fn form(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
|
||||
}
|
||||
|
||||
/// The character `c` is the raised or lowered form of, or `None` when it is no such form.
|
||||
pub fn plain(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text`
|
||||
/// when there is none.
|
||||
fn span(text: &str, range: Option<&Range<usize>>) -> Range<usize> {
|
||||
let Some(range) = range else { return 0..text.len() };
|
||||
let mut start = range.start.min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start = start.saturating_sub(1);
|
||||
}
|
||||
let mut end = range.end.min(text.len());
|
||||
while !text.is_char_boundary(end) {
|
||||
end = end.saturating_add(1);
|
||||
}
|
||||
start..end
|
||||
}
|
||||
|
||||
/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in
|
||||
/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no
|
||||
/// form for (a space, a `q`) don't count toward that state.
|
||||
pub fn is_in(text: &str, range: Option<&Range<usize>>, script: Script) -> bool {
|
||||
let target = text.get(span(text, range)).unwrap_or_default();
|
||||
target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none())
|
||||
}
|
||||
|
||||
/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when
|
||||
/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise
|
||||
/// each character that has a form takes it. Returns the new text and the range the converted
|
||||
/// characters now take, or `None` when nothing changes.
|
||||
///
|
||||
/// Mixed plain/scripted input normalizes to the target script and then to plain text; for
|
||||
/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original
|
||||
/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo.
|
||||
pub fn toggle(text: &str, range: Option<&Range<usize>>, script: Script) -> Option<(String, Range<usize>)> {
|
||||
let span = span(text, range);
|
||||
let target = text.get(span.clone()).filter(|t| !t.is_empty())?;
|
||||
let converted: String = if is_in(text, Some(&span), script) {
|
||||
target.chars().map(|c| script.plain(c).unwrap_or(c)).collect()
|
||||
} else {
|
||||
target.chars().map(|c| script.form(c).unwrap_or(c)).collect()
|
||||
};
|
||||
if converted == target {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(text.len().saturating_add(converted.len()));
|
||||
out.push_str(text.get(..span.start).unwrap_or_default());
|
||||
out.push_str(&converted);
|
||||
out.push_str(text.get(span.end..).unwrap_or_default());
|
||||
let end = span.start.saturating_add(converted.len());
|
||||
Some((out, span.start..end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_form_toggles_back_to_its_character() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
for &(plain, form) in script.pairs() {
|
||||
let s = plain.to_string();
|
||||
let (raised, range) = toggle(&s, None, script).unwrap();
|
||||
assert_eq!(raised, form.to_string());
|
||||
assert_eq!(range, 0..raised.len());
|
||||
assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}");
|
||||
}
|
||||
}
|
||||
assert_eq!(toggle("-", None, Script::Super), None);
|
||||
assert_eq!(toggle("-", None, Script::Sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_script_round_trips_preserve_both_minus_characters() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
let text = "−2 -3";
|
||||
let once = toggle(text, None, script).unwrap().0;
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_characters_unicode_cannot_raise_toggles_back() {
|
||||
// q has no superscript form and Q no subscript one; the space neither.
|
||||
for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] {
|
||||
let (once, _) = toggle(text, None, script).unwrap();
|
||||
assert_eq!(once, toggled);
|
||||
assert!(is_in(&once, None, script));
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}");
|
||||
}
|
||||
// Nothing to raise: nothing changes.
|
||||
assert_eq!(toggle("q Q", None, Script::Super), None);
|
||||
assert!(!is_in("q Q", None, Script::Super));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_converts_only_its_characters_and_follows_them() {
|
||||
let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap();
|
||||
assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8));
|
||||
assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7));
|
||||
// A mixed range takes the form throughout.
|
||||
assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂");
|
||||
assert!(!is_in("H₂2", None, Script::Sub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() {
|
||||
for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] {
|
||||
assert!(!is_in(mixed, None, script));
|
||||
let (once, range) = toggle(mixed, None, script).unwrap();
|
||||
assert_eq!(once, uniform);
|
||||
assert_eq!(range, 0..uniform.len());
|
||||
assert!(is_in(&once, None, script));
|
||||
let (twice, range) = toggle(&once, None, script).unwrap();
|
||||
assert_eq!(twice, plain);
|
||||
assert_eq!(range, 0..plain.len());
|
||||
assert_ne!(twice, mixed, "a second toggle normalizes the original mixture");
|
||||
assert_eq!(toggle(&twice, None, script).unwrap().0, uniform);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ranges_are_clamped_to_whole_characters() {
|
||||
// Byte 1 is inside ²: widened to the whole character on each side.
|
||||
assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1));
|
||||
assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None);
|
||||
assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3));
|
||||
assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None);
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(toggle("x2", Some(&backwards), Script::Super), None);
|
||||
assert_eq!(toggle("", None, Script::Sub), None);
|
||||
assert!(!is_in("", None, Script::Sub));
|
||||
}
|
||||
}
|
||||
@@ -618,13 +618,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<
|
||||
}
|
||||
|
||||
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
|
||||
use image::imageops::{FilterType, resize};
|
||||
Some(match n {
|
||||
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||||
_ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||||
1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
|
||||
_ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
|
||||
/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than
|
||||
/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the
|
||||
/// process. Such a one is scaled across first, which holds no more pixels than one of the two has.
|
||||
/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as
|
||||
/// it was.
|
||||
fn scaled<P: image::Pixel<Subpixel = u8> + 'static>(img: &image::ImageBuffer<P, Vec<u8>>, w: u32, h: u32) -> image::ImageBuffer<P, Vec<u8>> {
|
||||
use image::imageops::{FilterType, resize};
|
||||
let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
|
||||
let largest = (iw * ih).max(u64::from(w) * u64::from(h));
|
||||
if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
|
||||
resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
|
||||
} else {
|
||||
resize(img, w, h, FilterType::CatmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||||
@@ -1244,6 +1260,66 @@ mod dimension_tests {
|
||||
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
|
||||
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
|
||||
}
|
||||
|
||||
/// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
|
||||
/// the first three shapes asked for 160 GB and more on the way, and that aborted the process.
|
||||
/// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way
|
||||
/// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions
|
||||
/// resample as they always did.
|
||||
#[test]
|
||||
fn resampling_across_proportions_takes_the_memory_of_neither_product() {
|
||||
let shapes = [
|
||||
(100_000u32, 1u32, 1u32, 100_000u32, 1usize),
|
||||
(1_000_000, 1, 4, 100_000, 1),
|
||||
(100_000, 1, 1, 100_000, 3),
|
||||
(1, 100_000, 100_000, 1, 1),
|
||||
(1, 100_000, 100_000, 1, 3),
|
||||
];
|
||||
for (w, h, nw, nh, n) in shapes {
|
||||
let px = vec![0x60; (w * h) as usize * n];
|
||||
let out = resample(&px, n, w, h, nw, nh).unwrap();
|
||||
assert_eq!(out.len(), (nw * nh) as usize * n);
|
||||
assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
|
||||
}
|
||||
let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
|
||||
let px: Vec<u8> = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
|
||||
let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
|
||||
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
|
||||
}
|
||||
|
||||
/// The long way round is for proportions far apart: up to a quarter over the larger picture the
|
||||
/// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
|
||||
/// out the same either way.
|
||||
#[test]
|
||||
fn proportions_up_to_the_limit_resample_as_they_did() {
|
||||
let noise = |w: u32, h: u32| -> Vec<u8> {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
(0..w * h)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let direct = |px: &[u8], w, h, nw, nh| {
|
||||
let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
|
||||
image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
|
||||
};
|
||||
// Each exactly a quarter over the larger: the source's size, then the target's.
|
||||
for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
|
||||
let px = noise(w, h);
|
||||
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
|
||||
}
|
||||
// A step past it goes the long way, which is how to tell.
|
||||
let px = noise(25, 15);
|
||||
assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
|
||||
// A picture of the size asked for comes through as it is.
|
||||
let px = noise(20, 10);
|
||||
assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod nonseparable_gray;
|
||||
mod open_type_cff_simple_font;
|
||||
mod rotated_image_snap;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod stray_q_in_a_form;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is
|
||||
//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a
|
||||
//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned
|
||||
//! back, must match the upright one. The image itself sits at fractional coordinates: an upright
|
||||
//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was
|
||||
//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale
|
||||
//! and offset (most of its pixels off, by up to 217 levels).
|
||||
//!
|
||||
//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil
|
||||
//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another
|
||||
//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so
|
||||
//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are
|
||||
//! not snapped upright or turned, and were already the same both ways; they stay in the test as a
|
||||
//! guard.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A PDF of the given objects (object 1 the catalog), with a cross-reference table.
|
||||
fn pdf(objects: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(b"\nendstream");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Image size in samples.
|
||||
const W: usize = 400;
|
||||
const H: usize = 500;
|
||||
|
||||
/// A busy grey pattern with edges both ways, as a scan has.
|
||||
fn grey_samples() -> Vec<u8> {
|
||||
(0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect()
|
||||
}
|
||||
|
||||
/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes.
|
||||
fn bit_samples() -> Vec<u8> {
|
||||
let row = W.div_ceil(8);
|
||||
let mut out = vec![0u8; row * H];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 {
|
||||
out[y * row + x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn alpha_samples(w: usize, h: usize) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect()
|
||||
}
|
||||
|
||||
/// The kinds of image drawn.
|
||||
const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"];
|
||||
|
||||
/// The image of `kind` as objects 5 (and 6, its soft mask).
|
||||
fn image(kind: &str, interpolate: bool) -> Vec<Vec<u8>> {
|
||||
let image = |depth: u8, extra: &str, data: &[u8]| {
|
||||
let dict = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}"
|
||||
);
|
||||
stream(&dict, data)
|
||||
};
|
||||
let mask = |w: usize, h: usize| {
|
||||
let dict =
|
||||
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}");
|
||||
stream(&dict, &alpha_samples(w, h))
|
||||
};
|
||||
match kind {
|
||||
"8-bit grey" => vec![image(8, "", &grey_samples())],
|
||||
"1-bit grey" => vec![image(1, "", &bit_samples())],
|
||||
"stencil mask" => vec![stream(
|
||||
&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"),
|
||||
&bit_samples(),
|
||||
)],
|
||||
"soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)],
|
||||
_ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)],
|
||||
}
|
||||
}
|
||||
|
||||
/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn
|
||||
/// at fractional coordinates inside it, in a colour (a stencil mask paints it).
|
||||
fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"),
|
||||
];
|
||||
objects.extend(image(kind, interpolate));
|
||||
pdf(&objects)
|
||||
}
|
||||
|
||||
/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel,
|
||||
/// and the 400 × 500 image is shrunk to about 250 × 300 pixels.
|
||||
fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage {
|
||||
let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{kind}: {:?}", out.error);
|
||||
out
|
||||
}
|
||||
|
||||
fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] {
|
||||
let i = ((y * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
}
|
||||
|
||||
/// The largest channel difference between the turned render and the upright one turned the same
|
||||
/// way.
|
||||
fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 {
|
||||
let upright = render(0, kind, interpolate);
|
||||
let turned = render(rotate, kind, interpolate);
|
||||
let (w, h) = (upright.width, upright.height);
|
||||
// `/Rotate` turns the page clockwise.
|
||||
let expected = |x: u32, y: u32| match rotate {
|
||||
90 => rgb(&upright, y, h - 1 - x),
|
||||
180 => rgb(&upright, w - 1 - x, h - 1 - y),
|
||||
_ => rgb(&upright, w - 1 - y, x),
|
||||
};
|
||||
let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) };
|
||||
assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}");
|
||||
let mut worst = 0;
|
||||
for y in 0..th {
|
||||
for x in 0..tw {
|
||||
let (got, want) = (rgb(&turned, x, y), expected(x, y));
|
||||
worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max);
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() {
|
||||
for kind in KINDS {
|
||||
for interpolate in [true, false] {
|
||||
for rotate in [90, 180, 270] {
|
||||
let worst = turned_difference(rotate, kind, interpolate);
|
||||
assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec<u8> {
|
||||
/// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and
|
||||
/// never panics on malformed input.
|
||||
pub fn try_oid(dotted: &str) -> Result<Vec<u8>, SignError> {
|
||||
let mut arcs = dotted.split('.').map(|a| a.parse::<u64>().map_err(|_| bad("invalid OID arc")));
|
||||
let mut arcs = dotted.split('.').map(|a| {
|
||||
if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return Err(bad("invalid OID arc"));
|
||||
}
|
||||
a.parse::<u64>().map_err(|_| bad("invalid OID arc"))
|
||||
});
|
||||
let first = arcs.next().ok_or_else(|| bad("empty OID"))??;
|
||||
let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??;
|
||||
if first > 2 || (first < 2 && second >= 40) {
|
||||
@@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn try_oid_rejects_signs_and_empty_arcs() {
|
||||
for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] {
|
||||
assert!(try_oid(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let o = oid("1.2.840.113549.1.7.2");
|
||||
|
||||
@@ -20,6 +20,7 @@ craft-quit = { workspace = true, features = ["egui"] }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
@@ -96,6 +97,7 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
|
||||
skrifa = "0.44"
|
||||
proptest = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,7 @@ impl PdfCraftApp {
|
||||
default_mode: Mode::AllTools,
|
||||
view_defaults: Default::default(),
|
||||
units: Default::default(),
|
||||
blank_paper: crate::paper::Paper::system(),
|
||||
date_format_draft: None,
|
||||
mode_override: None,
|
||||
left: LeftPanel::AllTools,
|
||||
@@ -118,6 +119,7 @@ impl PdfCraftApp {
|
||||
all_tools_query: String::new(),
|
||||
all_tools_tab: (None, false),
|
||||
recent: Vec::new(),
|
||||
recent_limit: crate::RECENT_FILES_DEFAULT,
|
||||
reopen_last_session: false,
|
||||
restore_last_view: true,
|
||||
last_views: Vec::new(),
|
||||
@@ -141,6 +143,8 @@ impl PdfCraftApp {
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
pinned_tools: Vec::new(),
|
||||
pin_tool_search: String::new(),
|
||||
toast: None,
|
||||
toast_script: None,
|
||||
script_messages: std::collections::VecDeque::new(),
|
||||
@@ -228,7 +232,6 @@ impl PdfCraftApp {
|
||||
last_copied_file: None,
|
||||
system_clipboard: true,
|
||||
clipboard_override: None,
|
||||
v_pressed: false,
|
||||
attach_override: None,
|
||||
duplicate_draft: None,
|
||||
page_props_draft: None,
|
||||
@@ -358,6 +361,46 @@ impl PdfCraftApp {
|
||||
pub fn render_pending(&self) -> bool {
|
||||
self.views.iter().any(|v| v.render_pending())
|
||||
}
|
||||
|
||||
/// Check whether a tool group is pinned to Home.
|
||||
pub fn is_tool_pinned(&self, id: &str) -> bool {
|
||||
self.pinned_tools.iter().any(|t| t == id)
|
||||
}
|
||||
|
||||
/// Pin a tool group to Home. Returns true if newly pinned.
|
||||
pub fn pin_tool(&mut self, id: &str) -> bool {
|
||||
if pdfcraft_engine::catalog::group(id).is_none() {
|
||||
return false;
|
||||
}
|
||||
if !self.is_tool_pinned(id) {
|
||||
self.pinned_tools.push(id.to_string());
|
||||
if let Some(g) = pdfcraft_engine::catalog::group(id) {
|
||||
self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Unpin a tool group from Home.
|
||||
pub fn unpin_tool(&mut self, id: &str) {
|
||||
if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) {
|
||||
self.pinned_tools.remove(pos);
|
||||
if let Some(g) = pdfcraft_engine::catalog::group(id) {
|
||||
self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a tool group's pinned state on Home. Returns whether it is now pinned.
|
||||
pub fn toggle_pin_tool(&mut self, id: &str) -> bool {
|
||||
if self.is_tool_pinned(id) {
|
||||
self.unpin_tool(id);
|
||||
false
|
||||
} else {
|
||||
self.pin_tool(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for PdfCraftApp {
|
||||
@@ -367,6 +410,10 @@ impl eframe::App for PdfCraftApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// The button's release a window move or resize kept from egui.
|
||||
window_controls::release_after_desktop_grab(ctx, raw);
|
||||
// The wheel after Alt+Tab back, before the mouse has moved (#1518).
|
||||
let known = ctx.input(|i| i.pointer.latest_pos().is_some());
|
||||
let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size());
|
||||
drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx));
|
||||
self.prepare_raw_input(ctx, raw);
|
||||
if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str {
|
||||
tl!("The document's security settings don't allow printing")
|
||||
}
|
||||
|
||||
enum ClipboardPaste {
|
||||
Text,
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Whether text and images may be copied out of document `id`; says why not when they may
|
||||
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
|
||||
@@ -22,14 +27,83 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
|
||||
pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
|
||||
// ⌘C arrives as a Copy event on most platforms.
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
|
||||
if !copy || ctx.egui_wants_keyboard_input() {
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let pastes: Vec<_> = ctx.input(|input| {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
|
||||
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
|
||||
if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
|
||||
self.receive_pastes(i, pastes, false);
|
||||
return;
|
||||
}
|
||||
self.copy_selected_text(i, ctx);
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
|
||||
let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
if copy
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.copy_page_image(sel.page, sel.index);
|
||||
}
|
||||
if cut
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.cut_page_image(sel.page, sel.index);
|
||||
}
|
||||
if !pastes.is_empty() {
|
||||
self.receive_pastes(i, pastes, true);
|
||||
return;
|
||||
}
|
||||
if paste {
|
||||
let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. }));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let system_has_text = false;
|
||||
|
||||
if !has_override_image && system_has_text {
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let page = self.views.get(i).map_or(0, |v| v.current);
|
||||
self.paste_image(page, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
|
||||
let page = self.views.get(view).map_or(0, |view| view.current);
|
||||
for paste in pastes {
|
||||
match paste {
|
||||
ClipboardPaste::Text => {
|
||||
// Text fields may own the paste; the canvas cache still observes its contents.
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
}
|
||||
ClipboardPaste::Image(image) if images_allowed => {
|
||||
self.paste_received_image(page, &image);
|
||||
}
|
||||
ClipboardPaste::Image(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
@@ -79,3 +153,222 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
fn delivered() -> egui::Event {
|
||||
egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)))
|
||||
}
|
||||
|
||||
fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn image_app() -> PdfCraftApp {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] });
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) -> usize {
|
||||
ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
app.session.get(app.views[0].id).unwrap().page_images(0).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_pastes_for_either_modifier_release_order() {
|
||||
for command_first in [true, false] {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]);
|
||||
let releases = if command_first {
|
||||
vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]
|
||||
} else {
|
||||
vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]
|
||||
};
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste");
|
||||
assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste");
|
||||
assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() {
|
||||
for explicit_press in [false, true] {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()];
|
||||
if explicit_press {
|
||||
first.push(key(true, egui::Modifiers::COMMAND));
|
||||
}
|
||||
first.push(key(false, egui::Modifiers::COMMAND));
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, first), 1);
|
||||
// The integration drops Windows-key identity; Win+V can leave only this release.
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]),
|
||||
1,
|
||||
"opening history must not paste"
|
||||
);
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
|
||||
2,
|
||||
"choosing an item produces exactly one intentional paste"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
|
||||
assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_is_authoritative_and_not_duplicated_by_keys() {
|
||||
let mut app = image_app();
|
||||
app.system_clipboard = true;
|
||||
app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into()));
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1);
|
||||
assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_then_image_batch_pastes_image_and_keeps_its_cache() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1);
|
||||
assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1);
|
||||
assert!(app.last_copied_image.is_none());
|
||||
assert!(app.last_copied_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_delivered_images_in_one_batch_each_paste_once() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_control_shortcut_still_pastes_once_without_an_image_event() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_obeys_focus_modal_and_permission_guards() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = image_app();
|
||||
app.palette_open = true;
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0);
|
||||
app.palette_open = false;
|
||||
app.views[0].organize = true;
|
||||
assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0);
|
||||
app.views[0].organize = false;
|
||||
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
|
||||
permissions_password: Some("owner".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
|
||||
let mut restricted = PdfCraftApp::new();
|
||||
restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification());
|
||||
assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_an_image_less_release_is_silent_but_explicit_paste_explains() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.toast = None;
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(
|
||||
egui::RawInput {
|
||||
events: vec![egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|ui| app.clipboard_keys(0, ui.ctx()),
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.toast.is_none());
|
||||
assert!(!app.paste_image(0, None));
|
||||
assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_text_paste_release_is_silent_and_later_image_paste_still_works() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255]));
|
||||
app.palette_open = true; // A text field owns the first event.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.last_copied_image.is_none());
|
||||
app.palette_open = false;
|
||||
app.toast = None;
|
||||
ctx.run_ui(
|
||||
egui::RawInput {
|
||||
events: vec![egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|ui| app.clipboard_keys(0, ui.ctx()),
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.toast.is_none(), "the text gesture's key release must be silent");
|
||||
app.clipboard_override = None;
|
||||
let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)));
|
||||
ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_failed_keyboard_cut_keeps_the_image_selection() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.views[0].image_selection.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,12 @@ impl PdfCraftApp {
|
||||
_ => return Err("default-page-gaps must be on or off".into()),
|
||||
};
|
||||
}
|
||||
("recent-files", _) => {
|
||||
let max = crate::RECENT_FILES_MAX;
|
||||
let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?;
|
||||
self.recent_limit = limit;
|
||||
self.recent.truncate(limit);
|
||||
}
|
||||
("cover", Some(v)) => {
|
||||
let on = match value {
|
||||
"on" => true,
|
||||
|
||||
@@ -333,7 +333,7 @@ impl PdfCraftApp {
|
||||
macos_association::repair(std::path::Path::new(&p));
|
||||
self.recent.retain(|r| r.path != p);
|
||||
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
|
||||
self.recent.truncate(12);
|
||||
self.recent.truncate(self.recent_limit);
|
||||
}
|
||||
// What the form's scripts said while it opened (messages, errors) shows now, not with
|
||||
// the next edit.
|
||||
|
||||
@@ -3,7 +3,6 @@ use super::*;
|
||||
impl PdfCraftApp {
|
||||
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::Key;
|
||||
let pasted = self.paste_key_released(ctx);
|
||||
// "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document
|
||||
// properties; Escape cancels it rather than clearing a selection), and nothing may run
|
||||
// underneath it. They are read here, before the canvas can consume them.
|
||||
@@ -78,8 +77,7 @@ impl PdfCraftApp {
|
||||
prepare::nudge_keys(ctx, &mut self.views[i], doc);
|
||||
}
|
||||
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
|
||||
self.copy_key(i, ctx);
|
||||
self.paste_image_key(i, ctx, pasted);
|
||||
self.clipboard_keys(i, ctx);
|
||||
self.image_pick_keys(i, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ impl PdfCraftApp {
|
||||
let mut v = serde_json::json!({
|
||||
"side_panels": self.side_panels.to_json(self.left_open),
|
||||
"docking_right": self.right,
|
||||
"recent": self.recent,
|
||||
"recent": self.recent.iter().take(self.recent_limit).collect::<Vec<_>>(),
|
||||
"reopen_last_session": self.reopen_last_session,
|
||||
"restore_last_view": self.restore_last_view,
|
||||
"last_views": self.last_views_to_save(),
|
||||
@@ -139,8 +139,10 @@ impl PdfCraftApp {
|
||||
});
|
||||
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
|
||||
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
|
||||
v["recent_limit"] = serde_json::json!(self.recent_limit);
|
||||
v["fill_text_color"] = serde_json::json!(self.fill_text_color);
|
||||
v["redact_prefs"] = self.redact_defaults.to_json();
|
||||
v["pinned_tools"] = serde_json::json!(self.pinned_tools);
|
||||
// Set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
|
||||
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
|
||||
@@ -180,6 +182,11 @@ impl PdfCraftApp {
|
||||
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
|
||||
self.recent = r;
|
||||
}
|
||||
// Nonsense keeps the limit it had; settings from before the limit have none.
|
||||
if let Some(limit) = v["recent_limit"].as_u64() {
|
||||
self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX));
|
||||
}
|
||||
self.recent.truncate(self.recent_limit);
|
||||
self.restore_layout_prefs(&v);
|
||||
self.restore_behavior_prefs(&v);
|
||||
self.restore_view_prefs(&v);
|
||||
@@ -207,6 +214,18 @@ impl PdfCraftApp {
|
||||
self.show_secured_notice = on;
|
||||
}
|
||||
self.pinned.restore(&v["pinned_folders"]);
|
||||
if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
|
||||
let mut tools = Vec::new();
|
||||
for item in items {
|
||||
if let Some(id) = item.as_str()
|
||||
&& pdfcraft_engine::catalog::group(id).is_some()
|
||||
&& !tools.iter().any(|t| t == id)
|
||||
{
|
||||
tools.push(id.to_string());
|
||||
}
|
||||
}
|
||||
self.pinned_tools = tools;
|
||||
}
|
||||
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
|
||||
self.set_theme_preference(preference);
|
||||
}
|
||||
|
||||
+607
-38
@@ -84,6 +84,175 @@ pub enum Fit {
|
||||
None,
|
||||
}
|
||||
|
||||
use crate::edit_text_ui::ImageHandle;
|
||||
|
||||
/// Extension trait on `egui::Painter` to draw polygons.
|
||||
pub trait PainterExt {
|
||||
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx;
|
||||
}
|
||||
|
||||
impl PainterExt for egui::Painter {
|
||||
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx {
|
||||
self.add(egui::Shape::convex_polygon(points, fill, stroke))
|
||||
}
|
||||
}
|
||||
|
||||
/// User-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
|
||||
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
|
||||
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
im.visible_rect.unwrap_or(im.rect)
|
||||
}
|
||||
|
||||
/// Screen-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
|
||||
/// full CTM-derived rect and the clipping path's rect.
|
||||
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect_f64(info, page, im.rect);
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let clip_r = xf.user_rect_f64(info, page, clip);
|
||||
let inter = full.intersect(clip_r);
|
||||
if inter.is_positive() {
|
||||
return inter;
|
||||
}
|
||||
}
|
||||
if let Some(vr) = im.visible_rect {
|
||||
let r = xf.user_rect_f64(info, page, vr);
|
||||
if r.is_positive() {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
full
|
||||
}
|
||||
|
||||
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
|
||||
/// with 8 resize handles and rotated local axes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ImageObb {
|
||||
pub s00: Pos2,
|
||||
pub s10: Pos2,
|
||||
pub s11: Pos2,
|
||||
pub s01: Pos2,
|
||||
pub s_bc: Pos2,
|
||||
pub s_rc: Pos2,
|
||||
pub s_tc: Pos2,
|
||||
pub s_lc: Pos2,
|
||||
pub s_rot: Pos2,
|
||||
pub screen_center: Pos2,
|
||||
pub u_axis: (f64, f64),
|
||||
pub v_axis: (f64, f64),
|
||||
pub is_rotated: bool,
|
||||
}
|
||||
|
||||
impl ImageObb {
|
||||
pub(crate) fn handle_cursor(&self, handle: ImageHandle) -> egui::CursorIcon {
|
||||
let direction = self.handle_pos(handle) - self.screen_center;
|
||||
match handle {
|
||||
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
|
||||
if direction.x * direction.y > 0.0 {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
}
|
||||
}
|
||||
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
|
||||
_ => egui::CursorIcon::ResizeVertical,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, visible: Rect, p: Pos2) -> bool {
|
||||
(visible.is_positive() && visible.contains(p)) || crate::edit_text_ui::quad_contains(self.corners(), p)
|
||||
}
|
||||
|
||||
pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
|
||||
let m = crate::edit_text_ui::Matrix(matrix);
|
||||
let p00 = m.apply(0.0, 0.0);
|
||||
let p10 = m.apply(1.0, 0.0);
|
||||
let p11 = m.apply(1.0, 1.0);
|
||||
let p01 = m.apply(0.0, 1.0);
|
||||
|
||||
let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1);
|
||||
let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1);
|
||||
let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1);
|
||||
let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1);
|
||||
|
||||
let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5);
|
||||
let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5);
|
||||
let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5);
|
||||
let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5);
|
||||
|
||||
let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25);
|
||||
let out_dir = s_tc - screen_center;
|
||||
let out_len = out_dir.length().max(1e-4);
|
||||
let out_norm = out_dir / out_len;
|
||||
let s_rot = s_tc + out_norm * 24.0;
|
||||
|
||||
let [a, b, c, d, ..] = matrix;
|
||||
let w_u = (a * a + b * b).sqrt().max(1e-6);
|
||||
let h_v = (c * c + d * d).sqrt().max(1e-6);
|
||||
let u_axis = (a / w_u, b / w_u);
|
||||
let v_axis = (c / h_v, d / h_v);
|
||||
// The fast rectangle path interprets labels in screen space. Use matrix
|
||||
// resizing whenever the image's user-space axes are turned or reflected
|
||||
// on screen, including page /Rotate and View > Rotate View.
|
||||
let u = s10 - s00;
|
||||
let v = s01 - s00;
|
||||
let is_rotated = u.y.abs() > 1e-4 || v.x.abs() > 1e-4 || u.x < 0.0 || v.y > 0.0;
|
||||
|
||||
Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center, u_axis, v_axis, is_rotated }
|
||||
}
|
||||
|
||||
pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self {
|
||||
let [a, b, c, d, ..] = im.matrix;
|
||||
let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4 || a < 0.0 || d < 0.0;
|
||||
let bounds = image_selectable_bounds(im);
|
||||
let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] {
|
||||
[bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]]
|
||||
} else {
|
||||
im.matrix
|
||||
};
|
||||
Self::from_matrix(matrix, xf, info, page)
|
||||
}
|
||||
|
||||
pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
|
||||
[
|
||||
ImageHandle::TopLeft,
|
||||
ImageHandle::TopCenter,
|
||||
ImageHandle::TopRight,
|
||||
ImageHandle::RightCenter,
|
||||
ImageHandle::BottomRight,
|
||||
ImageHandle::BottomCenter,
|
||||
ImageHandle::BottomLeft,
|
||||
ImageHandle::LeftCenter,
|
||||
]
|
||||
.map(|h| (h, self.handle_pos(h)))
|
||||
}
|
||||
|
||||
pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
|
||||
match handle {
|
||||
ImageHandle::TopLeft => self.s01,
|
||||
ImageHandle::TopCenter => self.s_tc,
|
||||
ImageHandle::TopRight => self.s11,
|
||||
ImageHandle::RightCenter => self.s_rc,
|
||||
ImageHandle::BottomRight => self.s10,
|
||||
ImageHandle::BottomCenter => self.s_bc,
|
||||
ImageHandle::BottomLeft => self.s00,
|
||||
ImageHandle::LeftCenter => self.s_lc,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn corners(&self) -> [Pos2; 4] {
|
||||
[self.s00, self.s10, self.s11, self.s01]
|
||||
}
|
||||
}
|
||||
|
||||
/// The page display as it was before full screen, put back on leaving it
|
||||
/// ([`DocView::enter_presentation`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -569,6 +738,104 @@ pub enum ViewAction {
|
||||
CameraNext(&'static str),
|
||||
}
|
||||
|
||||
/// Default page right-click menu actions.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum PageMenuAction {
|
||||
RotateCCW,
|
||||
RotateCW,
|
||||
Print,
|
||||
SelectTool,
|
||||
HandTool,
|
||||
MarqueeZoom,
|
||||
Copy,
|
||||
Paste(usize, [f64; 2]),
|
||||
AddComment(usize, [f64; 2]),
|
||||
AddBookmark,
|
||||
TakeSnapshot,
|
||||
EditPdf,
|
||||
AddText,
|
||||
AddImage,
|
||||
DeletePage(usize),
|
||||
ExportPdf,
|
||||
Find,
|
||||
DocProperties,
|
||||
FlattenComments,
|
||||
}
|
||||
|
||||
pub(crate) struct PageMenuPerms {
|
||||
pub can_modify: bool,
|
||||
pub can_assemble: bool,
|
||||
pub can_annotate: bool,
|
||||
pub can_print: bool,
|
||||
pub can_copy: bool,
|
||||
pub can_copy_items: bool,
|
||||
pub can_paste: bool,
|
||||
pub has_annotations: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option<PageMenuAction>) {
|
||||
use PageMenuAction as A;
|
||||
// Takes `choice` when `entry` was clicked, and closes the menu.
|
||||
let mut pick = |entry: egui::Response, ui: &mut egui::Ui, choice: PageMenuAction| {
|
||||
if entry.clicked() {
|
||||
*action = Some(choice);
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
let icon = |ui: &mut egui::Ui, name: &str, tip: &str| crate::icons::button(ui, name, 24.0, false, tip);
|
||||
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
pick(icon(ui, "rotate-ccw", tl!("Rotate view counterclockwise")), ui, A::RotateCCW);
|
||||
ui.add_enabled_ui(perms.can_print, |ui| pick(icon(ui, "printer", tl!("Print")), ui, A::Print));
|
||||
pick(icon(ui, "mouse-pointer-2", tl!("Select Tool")), ui, A::SelectTool);
|
||||
pick(icon(ui, "hand", tl!("Hand Tool")), ui, A::HandTool);
|
||||
pick(icon(ui, "zoom-in", tl!("Marquee Zoom")), ui, A::MarqueeZoom);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(
|
||||
ui.add_enabled(
|
||||
perms.can_copy_items,
|
||||
egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)),
|
||||
),
|
||||
ui,
|
||||
A::Copy,
|
||||
);
|
||||
pick(
|
||||
ui.add_enabled(
|
||||
perms.can_paste,
|
||||
egui::Button::new(tl!("Paste")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), pdfcraft_engine::commands::Shortcut::cmd("V"))),
|
||||
),
|
||||
ui,
|
||||
A::Paste(page, at),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))), ui, A::AddComment(page, at));
|
||||
if perms.has_annotations {
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))), ui, A::FlattenComments);
|
||||
}
|
||||
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))), ui, A::AddBookmark);
|
||||
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))), ui, A::TakeSnapshot);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())), ui, A::EditPdf);
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))), ui, A::AddText);
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))), ui, A::AddImage);
|
||||
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))), ui, A::DeletePage(page));
|
||||
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))), ui, A::ExportPdf);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.button(tl!("Find text or tools")), ui, A::Find);
|
||||
pick(ui.button(tl!("Rotate view clockwise")), ui, A::RotateCW);
|
||||
pick(ui.button(tl!("Document properties…")), ui, A::DocProperties);
|
||||
}
|
||||
|
||||
impl DocView {
|
||||
/// Whether a middle-button scrolling gesture is active (tests and automation).
|
||||
pub fn auto_scrolling(&self) -> bool {
|
||||
@@ -886,6 +1153,7 @@ struct PageEnv<'a> {
|
||||
fill_text_size: f64,
|
||||
fill_text_color: [f64; 3],
|
||||
today: (i64, u32, u32),
|
||||
can_paste_image: bool,
|
||||
}
|
||||
|
||||
/// What the page-scroll closure produces for the rest of the frame.
|
||||
@@ -903,6 +1171,7 @@ struct PageOut {
|
||||
open_initials: bool,
|
||||
open_signature: bool,
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
stamp_placed: bool,
|
||||
@@ -946,6 +1215,70 @@ struct PageSlot {
|
||||
|
||||
/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders
|
||||
/// it wants.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_image_suppressed(
|
||||
xf: &PageXform,
|
||||
painter: &egui::Painter,
|
||||
tex: egui::TextureId,
|
||||
u0: f32,
|
||||
v0: f32,
|
||||
u1: f32,
|
||||
v1: f32,
|
||||
suppress: Option<[f32; 4]>,
|
||||
is_full_page_tex: bool,
|
||||
) {
|
||||
let Some([su0, sv0, su1, sv1]) = suppress else {
|
||||
if is_full_page_tex {
|
||||
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
|
||||
} else {
|
||||
xf.paint_image(painter, tex, u0, v0, u1, v1);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let iu0 = u0.max(su0);
|
||||
let iu1 = u1.min(su1);
|
||||
let iv0 = v0.max(sv0);
|
||||
let iv1 = v1.min(sv1);
|
||||
if iu0 >= iu1 || iv0 >= iv1 {
|
||||
if is_full_page_tex {
|
||||
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
|
||||
} else {
|
||||
xf.paint_image(painter, tex, u0, v0, u1, v1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let map_uv = |pu: f32, pv: f32| -> (f32, f32) {
|
||||
if is_full_page_tex {
|
||||
(pu, pv)
|
||||
} else {
|
||||
let du = (u1 - u0).max(1e-6);
|
||||
let dv = (v1 - v0).max(1e-6);
|
||||
((pu - u0) / du, (pv - v0) / dv)
|
||||
}
|
||||
};
|
||||
let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| {
|
||||
if pu1 <= pu0 || pv1 <= pv0 {
|
||||
return;
|
||||
}
|
||||
let (tu0, tv0) = map_uv(pu0, pv0);
|
||||
let (tu1, tv1) = map_uv(pu1, pv1);
|
||||
xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1);
|
||||
};
|
||||
|
||||
if iv0 > v0 {
|
||||
draw_strip(u0, v0, u1, iv0);
|
||||
}
|
||||
if v1 > iv1 {
|
||||
draw_strip(u0, iv1, u1, v1);
|
||||
}
|
||||
if iu0 > u0 {
|
||||
draw_strip(u0, iv0, iu0, iv1);
|
||||
}
|
||||
if u1 > iu1 {
|
||||
draw_strip(iu1, iv0, u1, iv1);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) {
|
||||
let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
@@ -988,13 +1321,33 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
|
||||
Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)),
|
||||
None => frame.wanted.push((i, want_scale, want_tag, None)),
|
||||
}
|
||||
let doc = frame.env.doc;
|
||||
let active_drag_suppress = if let Some(sel) = view.image_selection.clone()
|
||||
&& sel.page == i
|
||||
&& sel.drag.is_some()
|
||||
{
|
||||
let images = cached_edit_images(view, doc, i);
|
||||
if let Some(img) = images.get(sel.index) {
|
||||
let obb = ImageObb::from_image(img, &xf, info, i);
|
||||
let uvs = obb.corners().map(|c| xf.screen_to_norm(c));
|
||||
let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0);
|
||||
let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0);
|
||||
let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0);
|
||||
let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0);
|
||||
if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match view.pages.get(&i) {
|
||||
Some(p) => {
|
||||
if !tiled && p.tag == want_tag {
|
||||
xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
|
||||
} else {
|
||||
// A backdrop, or a raster at an older scale until the new one arrives.
|
||||
xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -1005,14 +1358,21 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
|
||||
}
|
||||
}
|
||||
if tiled && r.intersects(visible) {
|
||||
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side));
|
||||
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched,
|
||||
/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing.
|
||||
fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) {
|
||||
fn paint_page_tiles(
|
||||
ui: &egui::Ui,
|
||||
view: &mut DocView,
|
||||
frame: &mut PageFrame<'_>,
|
||||
slot: &PageSlot,
|
||||
(pw_pt, ph_pt, max_side): (f32, f32, f32),
|
||||
active_drag_suppress: Option<[f32; 4]>,
|
||||
) {
|
||||
let PageEnv { ppp, scale, tag, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
let visible = frame.visible;
|
||||
@@ -1041,7 +1401,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
|
||||
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
|
||||
let [w, h] = tex.size();
|
||||
let (x, y) = (ox as f32, oy as f32);
|
||||
xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
|
||||
paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false);
|
||||
}
|
||||
// Visible tiles, nearest the viewport centre first: each tile costs a full
|
||||
// page interpret, so the workers sharpen what is looked at before the edges.
|
||||
@@ -1066,13 +1426,16 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
|
||||
frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
|
||||
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
|
||||
let (fw, fh) = (dw as f32, dh as f32);
|
||||
xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
|
||||
paint_image_suppressed(
|
||||
&xf.texel_aligned([dw as usize, dh as usize], ppp),
|
||||
painter,
|
||||
tex.id(),
|
||||
x as f32 / fw,
|
||||
y as f32 / fh,
|
||||
(x + w) as f32 / fw,
|
||||
(y + h) as f32 / fh,
|
||||
active_drag_suppress,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1327,9 +1690,18 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
info,
|
||||
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
|
||||
};
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action);
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
if tool == QuickTool::EditText {
|
||||
if others_own_pointer {
|
||||
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = None;
|
||||
}
|
||||
} else {
|
||||
crate::content_ui::sync_image_selection(view, i, &images, added);
|
||||
}
|
||||
}
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
@@ -1367,17 +1739,20 @@ fn edit_inventory(
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = match view.edit_images.get(&i) {
|
||||
Some((g, l)) if *g == generation => l.clone(),
|
||||
_ => {
|
||||
let l = doc.page_images(i);
|
||||
view.edit_images.insert(i, (generation, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = cached_edit_images(view, doc, i).to_vec();
|
||||
(lines, images)
|
||||
}
|
||||
|
||||
/// Images parsed once per page and document edit generation, shared by painting and menus.
|
||||
fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
|
||||
let generation = doc.edit_generation();
|
||||
let entry = view.edit_images.entry(page).or_insert_with(|| (generation, doc.page_images(page)));
|
||||
if entry.0 != generation {
|
||||
*entry = (generation, doc.page_images(page));
|
||||
}
|
||||
&entry.1
|
||||
}
|
||||
|
||||
/// What the page modules have under a point on one page, for the page router.
|
||||
struct PageHits<'a> {
|
||||
page: usize,
|
||||
@@ -1426,6 +1801,19 @@ impl PageHits<'_> {
|
||||
images,
|
||||
clip,
|
||||
} = *self;
|
||||
// Crop mode owns presses on its page, including the controls beside its
|
||||
// frame. Underlying paragraphs and the empty-page marquee must not take
|
||||
// a crop button's press before the image module receives it.
|
||||
if tool == QuickTool::EditText
|
||||
&& can_modify
|
||||
&& let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())
|
||||
{
|
||||
return vec![crate::page_router::Hit::new(
|
||||
crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body },
|
||||
crate::page_cursor::Layer::Handle,
|
||||
egui::CursorIcon::Default,
|
||||
)];
|
||||
}
|
||||
let fields = if preparing {
|
||||
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
|
||||
} else if tool == QuickTool::Select {
|
||||
@@ -1935,6 +2323,7 @@ fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn draw_pages(
|
||||
ui: &mut egui::Ui,
|
||||
viewport: Rect,
|
||||
@@ -1968,6 +2357,8 @@ fn draw_pages(
|
||||
prefs,
|
||||
preparing,
|
||||
selection_wheel,
|
||||
ppp,
|
||||
rects,
|
||||
single_paging,
|
||||
tool,
|
||||
unobstructed,
|
||||
@@ -2034,6 +2425,38 @@ fn draw_pages(
|
||||
if resp.secondary_clicked() {
|
||||
// Set again below when the right-click lands on a link.
|
||||
view.links.context = None;
|
||||
let mut over_image = false;
|
||||
if tool == QuickTool::EditText {
|
||||
let pointer_pos = ptr.pos;
|
||||
if let Some(p) = pointer_pos {
|
||||
for &page_idx in visible_pages {
|
||||
let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
|
||||
if !r.intersects(visible) {
|
||||
continue;
|
||||
}
|
||||
let xf =
|
||||
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
|
||||
let images = cached_edit_images(view, doc, page_idx).to_vec();
|
||||
for (idx, img) in images.iter().enumerate().rev() {
|
||||
let obb = ImageObb::from_image(img, &xf, info, page_idx);
|
||||
let b = image_screen_rect(img, &xf, info, page_idx);
|
||||
if obb.contains(b, p) {
|
||||
over_image = true;
|
||||
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
|
||||
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if over_image {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !over_image && tool == QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
let mut frame = PageFrame {
|
||||
env: *env,
|
||||
@@ -2063,20 +2486,22 @@ fn draw_pages(
|
||||
let PageOut {
|
||||
mut canvas_action,
|
||||
clicked_link,
|
||||
mut copy_selected,
|
||||
copy_selected,
|
||||
mut field_menu,
|
||||
field_placed,
|
||||
field_props,
|
||||
hover_text: _,
|
||||
image_action,
|
||||
mut image_action,
|
||||
open_boxes,
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action: _,
|
||||
primary_down: _,
|
||||
refused,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
let painter = ui.painter();
|
||||
if calc_order {
|
||||
view.calc_order.set_hover_page(calc_hover);
|
||||
@@ -2196,20 +2621,33 @@ fn draw_pages(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_none() {
|
||||
let selected = view.selected_text().is_some_and(|t| !t.is_empty());
|
||||
let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
|
||||
if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() {
|
||||
copy_selected = true;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
let can_paste_image = env.can_paste_image;
|
||||
let is_image_menu = tool == QuickTool::EditText
|
||||
&& (ui.ctx().data_mut(|d| d.get_temp::<bool>(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
|
||||
|| view.image_selection.clone().is_some_and(|sel| {
|
||||
let pointer_pos = ptr.pos;
|
||||
let Some(p) = pointer_pos else { return false };
|
||||
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let Some(img) = images.get(sel.index) else { return false };
|
||||
let obb = ImageObb::from_image(img, &xf, info, sel.page);
|
||||
let b = image_screen_rect(img, &xf, info, sel.page);
|
||||
obb.contains(b, p)
|
||||
}));
|
||||
if is_image_menu && let Some(sel) = view.image_selection.clone() {
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let at = view.comments.context_at.map(|(_, at)| at);
|
||||
let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
|
||||
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_some() {
|
||||
canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
|
||||
return;
|
||||
}
|
||||
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
|
||||
let mut copy_image = None;
|
||||
if view.comments.selected.is_none()
|
||||
&& let Some((page, at)) = view.comments.context_at
|
||||
{
|
||||
if let Some((page, at)) = view.comments.context_at {
|
||||
// The page's content is parsed once per right-click, not on every frame the menu is open.
|
||||
let key = egui::Id::unique("copy-image-under-menu");
|
||||
let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option<usize>)>(key)).filter(|c| c.0 == page && c.1 == at);
|
||||
@@ -2233,9 +2671,28 @@ fn draw_pages(
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
canvas_action =
|
||||
comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image);
|
||||
let has_copy_image = copy_image.is_some();
|
||||
if copy_image.is_some() {
|
||||
canvas_action = copy_image;
|
||||
}
|
||||
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
|
||||
let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image);
|
||||
let can_paste = can_paste_image && can_modify;
|
||||
let perms = PageMenuPerms {
|
||||
can_modify,
|
||||
can_assemble: doc.allows_assembly(),
|
||||
can_annotate: allowed,
|
||||
can_print: doc.allows_printing(),
|
||||
can_copy,
|
||||
can_copy_items,
|
||||
can_paste,
|
||||
has_annotations: !info.annotations.is_empty(),
|
||||
};
|
||||
default_page_context_menu(ui, page, at, perms, &mut page_action);
|
||||
});
|
||||
if !resp.context_menu_opened() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
}
|
||||
*out = PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
@@ -2249,6 +2706,7 @@ fn draw_pages(
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
stamp_placed,
|
||||
@@ -2359,6 +2817,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging:
|
||||
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
|
||||
// scroll the page just shown away from its top (or bottom).
|
||||
view.precise_scroll_until = now + 0.25;
|
||||
view.fling.stop();
|
||||
}
|
||||
// Paging owns the vertical axis in single-page view; leave it to the pager.
|
||||
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
|
||||
@@ -2992,6 +3451,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry:
|
||||
/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests.
|
||||
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let can_paste_key = egui::Id::unique("can-paste-image");
|
||||
let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) {
|
||||
let can = app.can_paste_image();
|
||||
ui.data_mut(|d| d.insert_temp(can_paste_key, can));
|
||||
can
|
||||
} else {
|
||||
ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false)
|
||||
};
|
||||
// Keyboard scrolling applies to this frame's page view only, never later.
|
||||
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
|
||||
if key_scroll != 0.0 {
|
||||
@@ -3229,16 +3696,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
fill_text_size: app.fill_text_size,
|
||||
fill_text_color: app.fill_text_color,
|
||||
today,
|
||||
can_paste_image,
|
||||
};
|
||||
let mut page_out = PageOut::default();
|
||||
let fade_strength = ui.spacing().scroll.fade.strength;
|
||||
if !app.show_page_edge_shadows {
|
||||
ui.spacing_mut().scroll.fade.strength = 0.0;
|
||||
}
|
||||
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| {
|
||||
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| {
|
||||
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
|
||||
});
|
||||
ui.spacing_mut().scroll.fade.strength = fade_strength;
|
||||
let PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
@@ -3252,6 +3715,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
primary_down,
|
||||
mut refused,
|
||||
stamp_placed,
|
||||
@@ -3291,8 +3755,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if preparing {
|
||||
crate::prepare::keys(ui.ctx(), view);
|
||||
}
|
||||
if tool != QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
crate::content_ui::release_image_selection(view, &added);
|
||||
}
|
||||
if editing_content {
|
||||
crate::content_ui::keys(ui.ctx(), view);
|
||||
crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::keys(ui.ctx(), view);
|
||||
@@ -3338,6 +3806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
field_props,
|
||||
field_menu,
|
||||
image_action,
|
||||
page_action,
|
||||
refused,
|
||||
open_signature,
|
||||
open_initials,
|
||||
@@ -3359,6 +3828,7 @@ struct FrameRequests {
|
||||
field_props: Option<(String, usize)>,
|
||||
field_menu: Option<FieldMenu>,
|
||||
image_action: Option<crate::edit_text_ui::ImageAction>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
refused: Option<String>,
|
||||
open_signature: bool,
|
||||
open_initials: bool,
|
||||
@@ -3373,12 +3843,14 @@ struct FrameRequests {
|
||||
}
|
||||
|
||||
/// Opens the dialogs and runs the app commands the frame asked for.
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) {
|
||||
let FrameRequests {
|
||||
open_props,
|
||||
field_props,
|
||||
field_menu,
|
||||
image_action,
|
||||
page_action,
|
||||
refused,
|
||||
open_signature,
|
||||
open_initials,
|
||||
@@ -3442,12 +3914,109 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => {
|
||||
app.views[index].image_selection = None;
|
||||
app.cut_page_image(page, image);
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => {
|
||||
if let Some(pt) = at {
|
||||
app.paste_image_at(page, pt);
|
||||
} else {
|
||||
app.paste_image(page, None);
|
||||
}
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
|
||||
app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page),
|
||||
Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => {
|
||||
if let Some(d) = app.session.get(app.views[index].id) {
|
||||
let images = d.page_images(page);
|
||||
if let Some(img) = images.get(i) {
|
||||
let r = img.visible_rect.unwrap_or(img.rect);
|
||||
let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0];
|
||||
app.views[index].comments.context_at = Some((page, center));
|
||||
app.views[index].comments.composer = Some(crate::comments::Composer {
|
||||
page,
|
||||
at: center,
|
||||
kind: crate::comments::ComposerKind::Note,
|
||||
text: String::new(),
|
||||
focus: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match page_action {
|
||||
Some(PageMenuAction::Copy) => {
|
||||
if let Some(sel) = app.views[index].image_selection.clone() {
|
||||
app.copy_page_image(sel.page, sel.index);
|
||||
} else if app.views[index].selected_text().is_some() {
|
||||
app.execute("edit.copy");
|
||||
} else if let Some((page, at)) = app.views[index].comments.context_at
|
||||
&& let Some(doc) = app.session.get(app.views[index].id)
|
||||
&& let Some(image) = doc.page_image_at(page, at)
|
||||
{
|
||||
app.copy_page_image(page, image);
|
||||
}
|
||||
}
|
||||
Some(PageMenuAction::Paste(page, at)) => {
|
||||
app.paste_image_at(page, at);
|
||||
}
|
||||
Some(PageMenuAction::RotateCCW) => {
|
||||
app.views[index].rotate_view(false);
|
||||
}
|
||||
Some(PageMenuAction::RotateCW) => {
|
||||
app.views[index].rotate_view(true);
|
||||
}
|
||||
Some(PageMenuAction::Print) => {
|
||||
app.open_print();
|
||||
}
|
||||
Some(PageMenuAction::SelectTool) => {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
Some(PageMenuAction::HandTool) => {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
Some(PageMenuAction::MarqueeZoom) => {
|
||||
app.quick_tool = QuickTool::MarqueeZoom;
|
||||
}
|
||||
Some(PageMenuAction::AddComment(page, at)) => {
|
||||
app.views[index].comments.composer =
|
||||
Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true });
|
||||
}
|
||||
Some(PageMenuAction::FlattenComments) => {
|
||||
app.views[index].comments.selected = None;
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false });
|
||||
}
|
||||
Some(PageMenuAction::AddBookmark) => {
|
||||
app.bookmark_action(crate::panels::BmAction::New);
|
||||
}
|
||||
Some(PageMenuAction::TakeSnapshot) => {
|
||||
app.quick_tool = QuickTool::Snapshot;
|
||||
}
|
||||
Some(PageMenuAction::EditPdf) => {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
Some(PageMenuAction::AddText) => {
|
||||
app.quick_tool = QuickTool::AddText;
|
||||
}
|
||||
Some(PageMenuAction::AddImage) => {
|
||||
app.add_image_dialog();
|
||||
}
|
||||
Some(PageMenuAction::DeletePage(page)) => {
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] });
|
||||
}
|
||||
Some(PageMenuAction::ExportPdf) => {
|
||||
app.select_tool("export");
|
||||
}
|
||||
Some(PageMenuAction::Find) => {
|
||||
app.views[index].open_find();
|
||||
}
|
||||
Some(PageMenuAction::DocProperties) => {
|
||||
app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(why) = refused {
|
||||
|
||||
@@ -32,14 +32,22 @@ pub(super) const SUPPRESS_SECS: f64 = 0.25;
|
||||
/// Short rolling extension while egui still has an unprocessed wheel tail.
|
||||
pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05;
|
||||
|
||||
// Keep the viewer's scroll identity tied to the document instead of its parent UI.
|
||||
// Keep the viewer's scroll identity tied to the document instead of its parent UI. Without
|
||||
// `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only.
|
||||
pub(super) fn page_scroll<R>(
|
||||
ui: &mut egui::Ui,
|
||||
scroll: egui::ScrollArea,
|
||||
id: egui::Id,
|
||||
fades: bool,
|
||||
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
|
||||
) -> egui::scroll_area::ScrollAreaOutput<R> {
|
||||
ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner
|
||||
let strength = ui.spacing().scroll.fade.strength;
|
||||
if !fades {
|
||||
ui.spacing_mut().scroll.fade.strength = 0.0;
|
||||
}
|
||||
let out = ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner;
|
||||
ui.spacing_mut().scroll.fade.strength = strength;
|
||||
out
|
||||
}
|
||||
|
||||
/// Run a push button's action (the ones that need no JavaScript engine).
|
||||
|
||||
@@ -239,6 +239,12 @@ impl DocView {
|
||||
self.texts.get(&page).cloned()
|
||||
}
|
||||
|
||||
/// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and
|
||||
/// find matches on a page all wait for it (tests and automation).
|
||||
pub fn has_page_text(&self, page: usize) -> bool {
|
||||
self.texts.contains_key(&page)
|
||||
}
|
||||
|
||||
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
|
||||
self.thumbs.get(&page).map(|t| &t.tex)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,16 @@ pub(crate) fn page_input(
|
||||
let u = info_page.view_to_user(vx, vy);
|
||||
let at = [f64::from(u[0]), f64::from(u[1])];
|
||||
let generation = doc.edit_generation();
|
||||
view.picked_image =
|
||||
doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation }));
|
||||
view.picked_image = doc.page_image_at(page, at).and_then(|index| {
|
||||
doc.page_images(page).get(index).and_then(|i| {
|
||||
let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
|
||||
if screen_rect.is_positive() && screen_rect.contains(p) {
|
||||
Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget the selected picture once the document has been edited since it was picked: Edit text &
|
||||
@@ -71,7 +79,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
|
||||
}
|
||||
|
||||
/// A file name without characters file systems refuse.
|
||||
fn safe_name(s: &str) -> String {
|
||||
pub(crate) fn safe_name(s: &str) -> String {
|
||||
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
|
||||
let cleaned = cleaned.trim().trim_matches('.').to_string();
|
||||
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
|
||||
@@ -171,7 +179,8 @@ impl PdfCraftApp {
|
||||
return;
|
||||
}
|
||||
// The document may have changed since the click: the same picture must still be there.
|
||||
let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect));
|
||||
let current =
|
||||
self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds));
|
||||
if current == Some(picked.rect) {
|
||||
self.copy_page_image_file(picked.page, picked.index);
|
||||
} else {
|
||||
@@ -195,6 +204,7 @@ impl PdfCraftApp {
|
||||
let copied = file.and_then(|(ext, bytes)| {
|
||||
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
|
||||
self.put_file_on_clipboard(&name, &bytes)?;
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = Some((name, bytes));
|
||||
Ok(ext)
|
||||
});
|
||||
@@ -213,7 +223,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
|
||||
let path = write_clipboard_file(&dir, name, bytes)?;
|
||||
arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string())
|
||||
crate::os_clipboard::with(|c| c.set().file_list(&[path])).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -56,18 +56,17 @@ impl PagePointer {
|
||||
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
|
||||
// it stops: on the frame one opens egui still gives the pages a press that lands under
|
||||
// it, and its click.
|
||||
let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
|
||||
// `contains_pointer` alone also holds a few points under the edge of an Area over the
|
||||
// pages (egui's interact radius), so the top layer there must be the pages' own. A
|
||||
// dialog covers them from the frame it opens, before egui knows its layer.
|
||||
let layer = ui.layer_id();
|
||||
let on_top =
|
||||
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
|
||||
let press_origin = origin.or_else(|| first_press(&edges));
|
||||
let held =
|
||||
resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
|
||||
let pos = at.filter(|p| held || on_top(p));
|
||||
if !held {
|
||||
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
|
||||
}
|
||||
Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
|
||||
Self { pos, origin: press_origin, edges, primary_down, any_down }
|
||||
}
|
||||
|
||||
/// Where the pointer is, when it is inside `r`.
|
||||
|
||||
@@ -42,8 +42,7 @@ pub(crate) enum Owner {
|
||||
pub(crate) enum PicturePart {
|
||||
/// The picture: a click selects it, a drag moves the selected one.
|
||||
Body,
|
||||
/// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag
|
||||
/// resizes it.
|
||||
/// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
|
||||
Corner(usize),
|
||||
/// The selected picture's rotate knob: a drag turns it.
|
||||
Knob,
|
||||
|
||||
@@ -2002,3 +2002,38 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale()
|
||||
h.state_mut().views[0].stale_tiles.clear();
|
||||
h.state_mut().views[0].tiles.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_handles_account_for_document_and_view_rotation() {
|
||||
for page_rotation in [0, 90, 180, 270] {
|
||||
for view_rotation in [0, 90, 180, 270] {
|
||||
let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
|
||||
let info = DocInfo {
|
||||
pages: vec![pdfcraft_render::PageInfo {
|
||||
width,
|
||||
height,
|
||||
label: String::new(),
|
||||
crop: [0.0, 0.0, 300.0, 400.0],
|
||||
rotation: page_rotation,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) };
|
||||
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height };
|
||||
let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0);
|
||||
let total = (page_rotation + view_rotation) % 360;
|
||||
assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned");
|
||||
for (handle, point) in obb.handles() {
|
||||
let direction = point - obb.screen_center;
|
||||
let cursor = match handle {
|
||||
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
|
||||
if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
|
||||
}
|
||||
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
|
||||
_ => egui::CursorIcon::ResizeVertical,
|
||||
};
|
||||
assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// Where `page` is drawn this frame, with its coordinate mapping.
|
||||
pub(crate) fn page_xform(&self, page: usize) -> Option<PageXform> {
|
||||
pub fn page_xform(&self, page: usize) -> Option<PageXform> {
|
||||
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ impl PageXform {
|
||||
Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1))
|
||||
}
|
||||
|
||||
pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
|
||||
self.user_rect(info, page, r.map(|v| v as f32))
|
||||
}
|
||||
|
||||
/// This transform resized to a raster of `px` device pixels (width, height before the view
|
||||
/// rotation), so each of its texels covers one screen pixel. The rectangle's corner must
|
||||
/// already be on a whole pixel ([`snap_to_pixels`]).
|
||||
@@ -148,4 +152,34 @@ impl PageXform {
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Draw a sub-rectangle of a whole-page raster texture with specific texture UVs covering the normalised page region.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint_image_region(
|
||||
&self,
|
||||
painter: &egui::Painter,
|
||||
tex: egui::TextureId,
|
||||
pu0: f32,
|
||||
pv0: f32,
|
||||
pu1: f32,
|
||||
pv1: f32,
|
||||
tu0: f32,
|
||||
tv0: f32,
|
||||
tu1: f32,
|
||||
tv1: f32,
|
||||
) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)];
|
||||
for (u, v, tu, tv) in corners {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space.
|
||||
pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
|
||||
self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_clipboard {
|
||||
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
|
||||
arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.last_snapshot = Some((w, h, out.rgba));
|
||||
Ok((w, h))
|
||||
|
||||
+127
-19
@@ -128,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
Dialog::Print => 820.0,
|
||||
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
|
||||
Dialog::About => 780.0,
|
||||
Dialog::PinTool => 540.0,
|
||||
_ => 520.0,
|
||||
});
|
||||
if dialog_body(ui, app, dialog, &mut req) {
|
||||
@@ -446,6 +447,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Dialog::PinTool => {
|
||||
if !dialog_pin_tool(ui, app, req) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1080,6 +1086,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
|
||||
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
|
||||
let d = &mut app.rotate_draft;
|
||||
let mut typing_to = false;
|
||||
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
@@ -1101,10 +1108,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
|
||||
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id);
|
||||
let to_label = ui.label(tl!("to"));
|
||||
typing_to =
|
||||
widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
});
|
||||
});
|
||||
@@ -1136,10 +1144,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
// OK while the end is still being typed (a screen reader can press it).
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, false);
|
||||
req.rotate_now = true;
|
||||
req.close = true;
|
||||
}
|
||||
@@ -1161,17 +1171,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe
|
||||
ui.add_space(8.0);
|
||||
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
|
||||
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
let typing_to = ui
|
||||
.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
typing_to
|
||||
})
|
||||
.inner;
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, false);
|
||||
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
|
||||
req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
|
||||
req.close = true;
|
||||
@@ -1370,13 +1384,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
|
||||
let d = &mut app.number_draft;
|
||||
d.to = d.to.clamp(1, n);
|
||||
d.from = d.from.clamp(1, d.to);
|
||||
d.from = d.from.clamp(1, n);
|
||||
let mut typing_to = false;
|
||||
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Pages"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add(egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n)));
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
|
||||
});
|
||||
ui.end_row();
|
||||
@@ -1403,18 +1418,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
|
||||
ui.end_row();
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
|
||||
ui.add_space(8.0);
|
||||
let preview = if d.from == d.to {
|
||||
// `to` can be below `from` while it is being typed.
|
||||
let preview = if d.to <= d.from {
|
||||
label(d.start)
|
||||
} else {
|
||||
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32))
|
||||
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX))))
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted),
|
||||
);
|
||||
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
|
||||
// Ordered here too: the footer's OK can be pressed while the end is still being typed.
|
||||
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1553,6 +1570,91 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
|
||||
req.close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
|
||||
let search_resp =
|
||||
ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0));
|
||||
search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…")));
|
||||
if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
|
||||
app.pin_tool_search.clear();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
|
||||
let query = app.pin_tool_search.trim().to_lowercase();
|
||||
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let mut toggle = None;
|
||||
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
let label = tl!(g.label);
|
||||
let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" • ")).unwrap_or_default();
|
||||
if !query.is_empty()
|
||||
&& !label.to_lowercase().contains(&query)
|
||||
&& !blurb.to_lowercase().contains(&query)
|
||||
&& !g.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = app.is_tool_pinned(g.id);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
|
||||
|
||||
let btn_w = 78.0;
|
||||
let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
|
||||
let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click());
|
||||
let btn_hovered = btn_resp.hovered();
|
||||
|
||||
let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
|
||||
(t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
|
||||
} else if btn_hovered {
|
||||
(t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
|
||||
} else {
|
||||
(egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
|
||||
};
|
||||
let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
|
||||
btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}")));
|
||||
|
||||
ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
|
||||
ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
|
||||
|
||||
let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
|
||||
widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
|
||||
widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
|
||||
|
||||
if btn_resp.clicked() || resp.clicked() {
|
||||
toggle = Some(g.id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = toggle {
|
||||
app.toggle_pin_tool(id);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
ui.add_space(16.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
@@ -1569,6 +1671,10 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req:
|
||||
req.recover = Some(false);
|
||||
req.close = true;
|
||||
}
|
||||
} else if dialog == Dialog::PinTool {
|
||||
if widgets::pill_button(ui, tl!("Done"), true).clicked() {
|
||||
req.close = true;
|
||||
}
|
||||
} else if dialog == Dialog::NumberPages {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
req.apply_number = true;
|
||||
@@ -1836,6 +1942,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
// Protect / Remove security replace the Properties dialog.
|
||||
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
|
||||
if modal_closed || req.close || replaces {
|
||||
// Only now: the number in Preferences can pass through 0 on its way to the one wanted.
|
||||
app.recent.truncate(app.recent_limit);
|
||||
app.dialog = None;
|
||||
app.props_draft = None;
|
||||
app.view_draft = None;
|
||||
|
||||
@@ -31,7 +31,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
|
||||
fn select_after_edit(
|
||||
view: &mut crate::DocView,
|
||||
edit: Edit,
|
||||
edit: &Edit,
|
||||
info: &pdfcraft_render::DocInfo,
|
||||
annots_before: &[pdfcraft_render::Annotation],
|
||||
quick_tool: crate::QuickTool,
|
||||
@@ -39,15 +39,15 @@ fn select_after_edit(
|
||||
match edit {
|
||||
Edit::MovePages { pages, to } => {
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let to = to.min(info.pages.len() - n);
|
||||
let to = (*to).min(info.pages.len() - n);
|
||||
view.select_pages(&(to..to + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::DuplicatePages { pages, at: Some(at) } => {
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let at = at.min(info.pages.len().saturating_sub(n));
|
||||
let at = (*at).min(info.pages.len().saturating_sub(n));
|
||||
view.select_pages(&(at..at + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]),
|
||||
Edit::DeletePages { .. } => view.select_pages(&[]),
|
||||
// A stroke drawn with the pen stays unselected, so the selection box and
|
||||
// author popup don't sit over the next stroke (#429).
|
||||
@@ -71,7 +71,7 @@ fn select_after_edit(
|
||||
view.comments.open_editor(a.page, n, String::new());
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)),
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,25 @@ impl PdfCraftApp {
|
||||
pub fn apply_edit(&mut self, edit: Edit) -> bool {
|
||||
let Some((i, id)) = self.active_ids() else { return false };
|
||||
let label = edit.label();
|
||||
let target_arrange_image = match &edit {
|
||||
Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => {
|
||||
let doc = self.session.get(id);
|
||||
let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned());
|
||||
let ambiguous = doc.is_some_and(|d| {
|
||||
if let Some(t) = &target {
|
||||
d.page_images(*page)
|
||||
.iter()
|
||||
.filter(|img| img.name == t.name && img.object == t.object && rect_matches(img.rect, t.rect))
|
||||
.count()
|
||||
> 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
target.map(|t| (t, ambiguous))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
|
||||
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
|
||||
} else {
|
||||
@@ -166,7 +185,11 @@ impl PdfCraftApp {
|
||||
view.comments.comment_added();
|
||||
}
|
||||
// Keep the pages the user acted on selected, where they now are.
|
||||
select_after_edit(view, edit, info, &annots_before, self.quick_tool);
|
||||
select_after_edit(view, &edit, info, &annots_before, self.quick_tool);
|
||||
if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
|
||||
let page_images = doc.page_images(*page);
|
||||
re_resolve_image_selection(view, *page, &target, ambiguous_before, &page_images);
|
||||
}
|
||||
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
|
||||
self.quick_tool = crate::QuickTool::Select;
|
||||
}
|
||||
@@ -187,6 +210,9 @@ impl PdfCraftApp {
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
// A drawing whose annotation wasn't added must not stay painted.
|
||||
view.comments.drawings_refused();
|
||||
if matches!(&edit, Edit::EditPageImage { change, .. } if !matches!(change, pdfcraft_engine::ImageEdit::Arrange(_))) {
|
||||
view.image_selection = None;
|
||||
}
|
||||
// A paragraph that couldn't be applied opens again with its typing, instead of
|
||||
// the box the click moved to.
|
||||
if let Edit::EditTextBlock { page, block, .. } = &edit
|
||||
@@ -287,13 +313,34 @@ impl PdfCraftApp {
|
||||
if !self.close_text_editors(i, id) {
|
||||
return;
|
||||
}
|
||||
let target_image = self.views.get(i).and_then(|v| v.image_selection.as_ref()).and_then(|s| {
|
||||
let doc = self.session.get(id)?;
|
||||
let images = doc.page_images(s.page);
|
||||
let target = images.get(s.index).cloned()?;
|
||||
let ambiguous =
|
||||
images.iter().filter(|img| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)).count() > 1;
|
||||
Some((s.page, target, ambiguous))
|
||||
});
|
||||
let had_selection = self.views.get(i).and_then(|v| v.image_selection.as_ref()).is_some();
|
||||
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
|
||||
match result {
|
||||
Ok(label) => {
|
||||
if let Some(doc) = self.session.get(id) {
|
||||
self.views[i].document_changed(&doc.info);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
view.picked_image = None;
|
||||
if let Some((page, target, ambiguous_before)) = target_image {
|
||||
let page_images = doc.page_images(page);
|
||||
re_resolve_image_selection(view, page, &target, ambiguous_before, &page_images);
|
||||
} else if had_selection {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.comments.selected = None;
|
||||
view.comments.history_stepped();
|
||||
}
|
||||
self.views[i].comments.history_stepped();
|
||||
let label = crate::i18n::edit_label(&label);
|
||||
if undo {
|
||||
self.notify_fmt("Undid {label}", &[("label", &label)]);
|
||||
@@ -783,6 +830,48 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
fn re_resolve_image_selection(
|
||||
view: &mut crate::DocView,
|
||||
page: usize,
|
||||
target: &pdfcraft_engine::PageImage,
|
||||
ambiguous_before: bool,
|
||||
page_images: &[pdfcraft_engine::PageImage],
|
||||
) {
|
||||
let exact_matches: Vec<usize> = page_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img)| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect))
|
||||
.map(|(idx, _)| idx)
|
||||
.collect();
|
||||
let new_idx = if !ambiguous_before && exact_matches.len() == 1 {
|
||||
exact_matches.first().copied()
|
||||
} else if !ambiguous_before && exact_matches.is_empty() {
|
||||
let name_obj_matches: Vec<usize> =
|
||||
page_images.iter().enumerate().filter(|(_, img)| img.name == target.name && img.object == target.object).map(|(idx, _)| idx).collect();
|
||||
if name_obj_matches.len() == 1 {
|
||||
name_obj_matches.first().copied()
|
||||
} else if name_obj_matches.is_empty() {
|
||||
let name_matches: Vec<usize> = page_images.iter().enumerate().filter(|(_, img)| img.name == target.name).map(|(idx, _)| idx).collect();
|
||||
if name_matches.len() == 1 { name_matches.first().copied() } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == page) {
|
||||
if let Some(idx) = new_idx {
|
||||
s.index = idx;
|
||||
} else {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single page a content edit changes (`None` for other edits). Like comment edits,
|
||||
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
|
||||
fn content_page(edit: &Edit) -> Option<usize> {
|
||||
|
||||
+137
-51
@@ -20,6 +20,57 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
|
||||
/// If `allow_unpin` is true, an unpin button is always accessible in the top right corner.
|
||||
fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
|
||||
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(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
|
||||
let mut unpin_clicked = false;
|
||||
if allow_unpin {
|
||||
let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
|
||||
let unpin_resp = ui.interact(unpin_rect, ui.scope_id().with(("unpin_card_btn", g.id)), Sense::click());
|
||||
unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Unpin tool")));
|
||||
if unpin_resp.hovered() {
|
||||
ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
|
||||
if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
|
||||
unpin_clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
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: 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(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, 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(body.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(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 unpin_clicked {
|
||||
Some(g.id)
|
||||
} else {
|
||||
if theme::hand(resp).clicked() {
|
||||
app.select_tool(g.id);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home page with the welcome text, community card and recent documents.
|
||||
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -73,44 +124,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
for id in RECOMMENDED {
|
||||
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::Role::Button, true, tl!(g.label)));
|
||||
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(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: 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(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, 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(body.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(
|
||||
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 theme::hand(resp).clicked() {
|
||||
app.select_tool(g.id);
|
||||
if app.is_tool_pinned(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
tool_card(app, ui, &t, g, false);
|
||||
}
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file")));
|
||||
@@ -132,31 +150,64 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(26.0);
|
||||
let mut unpin_tool = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
|
||||
app.dialog = Some(crate::Dialog::PinTool);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if app.pinned_tools.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Pin your favorite tools here for quick access.")).color(t.text_muted));
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
let pinned = app.pinned_tools.clone();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
for id in &pinned {
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
if let Some(to_unpin) = tool_card(app, ui, &t, g, true) {
|
||||
unpin_tool = Some(to_unpin);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(id) = unpin_tool {
|
||||
app.unpin_tool(id);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
ui.add_space(26.0);
|
||||
crate::folders_ui::section(app, ui);
|
||||
}
|
||||
|
||||
ui.add_space(26.0);
|
||||
let mut clear = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
// Preferences can turn the list off (#5572).
|
||||
if app.recent_limit > 0 {
|
||||
ui.add_space(26.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if app.recent.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if app.recent.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
|
||||
}
|
||||
let mut open = None;
|
||||
let mut remove = None;
|
||||
for r in &app.recent {
|
||||
for r in app.recent.iter().take(app.recent_limit) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
@@ -229,6 +280,41 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn review_home_harness() -> egui_kittest::Harness<'static, PdfCraftApp> {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.pinned_tools = vec!["edit".into()];
|
||||
let mut fonts = false;
|
||||
egui_kittest::Harness::builder().with_size(vec2(1500.0, 1200.0)).build_ui_state(
|
||||
move |ui, app| {
|
||||
if !fonts {
|
||||
ui.ctx().set_fonts(theme::font_definitions());
|
||||
fonts = true;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_pinned_recommended_tools_appear_once() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = review_home_harness();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_all_by_label("Edit a PDF").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_a_pinned_tool_can_be_unpinned_without_hover() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = review_home_harness();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Unpin tool").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().pinned_tools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_card_description_leaves_room_for_the_action() {
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! placeholders, plural forms) pick it up from the registry.
|
||||
//!
|
||||
//! # Looking strings up
|
||||
//! - [`tl!`](crate::tl) / [`t`]: a plain string in the current language. [`tr`]: in a given one.
|
||||
//! - `tl!` / [`t`]: a plain string in the current language. [`tr`]: in a given one.
|
||||
//! - [`tr_ctx`] (`tl_ctx!` in UI code): when one English word needs different translations.
|
||||
//! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items), so a
|
||||
//! translation survives rewording of the English text and can differ per command.
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
|
||||
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu القائمة
|
||||
File ملف
|
||||
Edit تحرير
|
||||
@@ -2792,6 +2796,25 @@ certificate Version الإصدار
|
||||
Make properties default جعل الخصائص افتراضية
|
||||
Pages selected الصفحات المحددة
|
||||
Page properties خصائص الصفحة
|
||||
Clear search مسح البحث
|
||||
Pin تثبيت
|
||||
Pin a tool… تثبيت أداة…
|
||||
Pin to Home تثبيت في الصفحة الرئيسية
|
||||
Pin tools to Home تثبيت الأدوات في الصفحة الرئيسية
|
||||
Pin your favorite tools here for quick access. ثبّت أدواتك المفضلة هنا للوصول السريع.
|
||||
Pinned مثبت
|
||||
Pinned Tools الأدوات المثبتة
|
||||
Search tools… البحث عن الأدوات…
|
||||
Select tools to display on your Home page for quick access. حدد الأدوات المراد عرضها على صفحتك الرئيسية للوصول السريع.
|
||||
Unpin from Home إلغاء التثبيت من الصفحة الرئيسية
|
||||
Unpin tool إلغاء تثبيت الأداة
|
||||
Transform image تحويل الصورة
|
||||
Select Tool أداة التحديد
|
||||
Hand Tool أداة اليد
|
||||
Marquee Zoom تكبير بالتحديد
|
||||
Edit this PDF تحرير هذا الملف PDF
|
||||
Export this PDF تصدير هذا الملف PDF
|
||||
Find text or tools البحث عن نص أو أدوات
|
||||
Create & Edit الإنشاء والتحرير
|
||||
Review & Compare المراجعة والمقارنة
|
||||
Forms & Signatures النماذج والتوقيعات
|
||||
@@ -2838,6 +2861,13 @@ certificate Version الإصدار
|
||||
field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name}
|
||||
two cells would both be named {name} ستحمل خليتان الاسم نفسه {name}
|
||||
a field named {name} already exists يوجد حقل باسم {name} بالفعل
|
||||
Arrange image ترتيب الصورة
|
||||
Bring forward إحضار للأمام
|
||||
Bring to front إحضار إلى المقدمة
|
||||
Crop قص
|
||||
Remove crop إزالة القص
|
||||
Send backward إرسال للخلف
|
||||
Send to back إرسال إلى المؤخرة
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today اليوم
|
||||
@@ -2855,3 +2885,10 @@ certificate Version الإصدار
|
||||
Options for this reply خيارات هذا الرد
|
||||
Export value قيمة التصدير
|
||||
Show Page Edge Shadows إظهار ظلال حواف الصفحات
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… بحث…
|
||||
Image pasted تم لصق الصورة
|
||||
No image on clipboard to paste لا توجد صورة في الحافظة للصقها
|
||||
Pinned {tool} to Home تم تثبيت {tool} في الرئيسية
|
||||
Unpinned {tool} from Home تم إلغاء تثبيت {tool} من الرئيسية
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -7,6 +7,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
About За програмата
|
||||
Contributors Сътрудници
|
||||
Models Модели
|
||||
@@ -2432,7 +2436,7 @@ animation effects Off Изключени
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала.
|
||||
Turn upside down (180°) Обърни наопаки (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната и област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
|
||||
Find untagged content Намиране на немаркирано съдържание
|
||||
No untagged content was found. Не е намерено немаркирано съдържание.
|
||||
Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание.
|
||||
@@ -2478,6 +2482,26 @@ animation effects Off Изключени
|
||||
Make properties default Задаване като свойства по подразбиране
|
||||
Pages selected Избрани страници
|
||||
Page properties Свойства на страницата
|
||||
Clear search Изчистване на търсенето
|
||||
Pin Закачане
|
||||
Pin a tool… Закачане на инструмент…
|
||||
Pin to Home Закачане към Начало
|
||||
Pin tools to Home Закачане на инструменти към Начало
|
||||
Pin your favorite tools here for quick access. Закачете любимите си инструменти тук за бърз достъп.
|
||||
Pinned Закачено
|
||||
Pinned Tools Закачени инструменти
|
||||
Search tools… Търсене на инструменти…
|
||||
Select tools to display on your Home page for quick access. Изберете инструменти за показване на началната страница за бърз достъп.
|
||||
Unpin from Home Откачане от Начало
|
||||
Unpin tool Откачане на инструмент
|
||||
Transform image Трансформиране на изображение
|
||||
Select Tool Инструмент за избор
|
||||
Hand Tool Инструмент „Ръка“
|
||||
Marquee Zoom Мащабиране с рамка
|
||||
Paste Постави
|
||||
Edit this PDF Редактирай този PDF
|
||||
Export this PDF Експортирай този PDF
|
||||
Find text or tools Търсене на текст или инструменти
|
||||
Create & Edit Създаване и редактиране
|
||||
Review & Compare Преглед и сравнение
|
||||
Forms & Signatures Формуляри и подписи
|
||||
@@ -2525,6 +2549,13 @@ animation effects Off Изключени
|
||||
field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name}
|
||||
two cells would both be named {name} две клетки биха имали едно и също име {name}
|
||||
a field named {name} already exists вече съществува поле с име {name}
|
||||
Arrange image Подреждане на изображението
|
||||
Bring forward Преместване напред
|
||||
Bring to front Преместване най-отпред
|
||||
Crop Изрязване
|
||||
Remove crop Премахване на изрязването
|
||||
Send backward Преместване назад
|
||||
Send to back Преместване най-отзад
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Днес
|
||||
@@ -2542,3 +2573,10 @@ animation effects Off Изключени
|
||||
Options for this reply Опции за този отговор
|
||||
Export value Стойност за експортиране
|
||||
Show Page Edge Shadows Показване на сенки по краищата на страниците
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Търсене…
|
||||
Image pasted Изображението е поставено
|
||||
No image on clipboard to paste Няма изображение в клипборда за поставяне
|
||||
Pinned {tool} to Home {tool} е закачен в Начало
|
||||
Unpinned {tool} from Home {tool} е откачен от Начало
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -9,6 +9,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menú
|
||||
File Fitxer
|
||||
Edit Edita
|
||||
@@ -2815,6 +2819,33 @@ theme Appearance Aspecte
|
||||
field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name}
|
||||
two cells would both be named {name} dues cel·les es dirien {name}
|
||||
a field named {name} already exists ja existeix un camp anomenat {name}
|
||||
Bring forward Porta endavant
|
||||
Bring to front Porta al capdavant
|
||||
Crop image Retalla la imatge
|
||||
Remove crop Elimina el retall
|
||||
Send backward Envia enrere
|
||||
Send to back Envia al fons
|
||||
Clear search Neteja la cerca
|
||||
Edit this PDF Edita aquest PDF
|
||||
Export this PDF Exporta aquest PDF
|
||||
Find text or tools Cerca text o eines
|
||||
Hand Tool Eina Mà
|
||||
Marquee Zoom Zoom d'enquadrament
|
||||
Pin Fixa
|
||||
Pin a tool… Fixa una eina…
|
||||
Pin to Home Fixa a l'Inici
|
||||
Pin tools to Home Fixa eines a l'Inici
|
||||
Pin your favorite tools here for quick access. Fixeu aquí les vostres eines preferides per a un accés ràpid.
|
||||
Pinned Fixat
|
||||
Pinned Tools Eines fixades
|
||||
Search tools… Cerca eines…
|
||||
Select Tool Eina Selecció
|
||||
Select tools to display on your Home page for quick access. Seleccioneu les eines que es mostraran a la pàgina d'Inici per a un accés ràpid.
|
||||
Transform image Transforma la imatge
|
||||
Unpin from Home Desenganxa de l'Inici
|
||||
Unpin tool Desenganxa l'eina
|
||||
Arrange image Organitza la imatge
|
||||
Crop Escapça
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Avui
|
||||
@@ -2838,3 +2869,10 @@ theme Appearance Aspecte
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply Opcions d'aquesta resposta
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Cerca…
|
||||
Image pasted Imatge enganxada
|
||||
No image on clipboard to paste No hi ha cap imatge al porta-retalls per enganxar
|
||||
Pinned {tool} to Home {tool} fixat a Inici
|
||||
Unpinned {tool} from Home {tool} desfixat d’Inici
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 3.
|
@@ -20,6 +20,10 @@
|
||||
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
|
||||
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu پێڕست
|
||||
File فایل
|
||||
Edit دەستکاریکردن
|
||||
@@ -2735,6 +2739,13 @@ certificate Version وەشان
|
||||
Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی
|
||||
Pages selected پەڕە هەڵبژێردراوەکان
|
||||
Page properties تایبەتمەندییەکانی پەڕە
|
||||
Arrange image ڕێکخستنی وێنە
|
||||
Bring forward هێنانە پێشەوە
|
||||
Bring to front هێنانە پێشەوەی سەرەکی
|
||||
Crop بڕینەوە
|
||||
Remove crop لابردنی بڕینەوە
|
||||
Send backward ناردنە دواوە
|
||||
Send to back ناردنە دواوەی سەرەکی
|
||||
Create & Edit دروستکردن و دەستکاریکردن
|
||||
Review & Compare پێداچوونەوە و بەراوردکردن
|
||||
Forms & Signatures فۆڕمەکان و واژووەکان
|
||||
@@ -2770,3 +2781,32 @@ certificate Version وەشان
|
||||
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
|
||||
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
|
||||
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… گەڕان…
|
||||
Image pasted وێنەکە لکێنرا
|
||||
No image on clipboard to paste هیچ وێنەیەک لە کلیپبۆرد نییە بۆ لکاندن
|
||||
Pinned {tool} to Home {tool} لە ماڵەوە جێگیرکرا
|
||||
Unpinned {tool} from Home {tool} لە ماڵەوە لابرا
|
||||
|
||||
# Home tools (AI-assisted; not separately native-reviewed)
|
||||
Clear search پاککردنەوەی گەڕان
|
||||
Pin جێگیرکردن
|
||||
Pin a tool… جێگیرکردنی ئامراز…
|
||||
Pin to Home جێگیرکردن لە ماڵەوە
|
||||
Pin tools to Home جێگیرکردنی ئامرازەکان لە ماڵەوە
|
||||
Pin your favorite tools here for quick access. ئامرازە دڵخوازەکانت لێرە جێگیر بکە بۆ دەستگەیشتنی خێرا.
|
||||
Pinned جێگیرکراو
|
||||
Pinned Tools ئامرازە جێگیرکراوەکان
|
||||
Search tools… گەڕان بەدوای ئامرازەکان…
|
||||
Select tools to display on your Home page for quick access. ئامرازەکان هەڵبژێرە بۆ پیشاندان لە ماڵەوە و دەستگەیشتنی خێرا.
|
||||
Unpin from Home لابردنی جێگیرکردن لە ماڵەوە
|
||||
Unpin tool لابردنی جێگیرکردنی ئامراز
|
||||
Transform image گۆڕینی شێوەی وێنە
|
||||
Select Tool ئامرازی هەڵبژاردن
|
||||
Hand Tool ئامرازی دەست
|
||||
Marquee Zoom گەورەکردنی ناوچەی هەڵبژێردراو
|
||||
Edit this PDF دەستکاریکردنی ئەم PDF ـە
|
||||
Export this PDF هەناردەکردنی ئەم PDF ـە
|
||||
Find text or tools دۆزینەوەی دەق یان ئامراز
|
||||
Crop image بڕینەوەی وێنە
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -19,6 +19,10 @@
|
||||
# commands is kept. Messages with a count but no `@plural` entry use count-neutral wording
|
||||
# ("Vybráno stránek: {n}"), because Czech has three plural forms.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Nabídka
|
||||
File Soubor
|
||||
Rotation: Otočení:
|
||||
@@ -2589,6 +2593,26 @@ animation effects Off Vypnuto
|
||||
Make properties default Nastavit vlastnosti jako výchozí
|
||||
Pages selected Vybrané stránky
|
||||
Page properties Vlastnosti stránky
|
||||
Clear search Vymazat hledání
|
||||
Pin Připnout
|
||||
Pin a tool… Připnout nástroj…
|
||||
Pin to Home Připnout na domovskou stránku
|
||||
Pin tools to Home Připnout nástroje na domovskou stránku
|
||||
Pin your favorite tools here for quick access. Připněte si sem oblíbené nástroje pro rychlý přístup.
|
||||
Pinned Připnuto
|
||||
Pinned Tools Připnuté nástroje
|
||||
Search tools… Hledat nástroje…
|
||||
Select tools to display on your Home page for quick access. Vyberte nástroje, které se mají zobrazovat na domovské stránce pro rychlý přístup.
|
||||
Unpin from Home Odepnout z domovské stránky
|
||||
Unpin tool Odepnout nástroj
|
||||
Transform image Transformovat obrázek
|
||||
Select Tool Nástroj pro výběr
|
||||
Hand Tool Nástroj ručička
|
||||
Marquee Zoom Zvětšit vybranou oblast
|
||||
Paste Vložit
|
||||
Edit this PDF Upravit toto PDF
|
||||
Export this PDF Exportovat toto PDF
|
||||
Find text or tools Hledat text nebo nástroje
|
||||
Create & Edit Vytváření a úpravy
|
||||
Review & Compare Revize a porovnání
|
||||
Forms & Signatures Formuláře a podpisy
|
||||
@@ -2793,7 +2817,6 @@ animation effects Off Vypnuto
|
||||
Organize Uspořádat
|
||||
Page {n} Stránka {n}
|
||||
Page {n} of {total} Stránka {n} z {total}
|
||||
Paste Vložit
|
||||
Previous Předchozí
|
||||
Previous (⇧⌘G) Předchozí (⇧⌘G)
|
||||
Print (⌘P) Tisk (⌘P)
|
||||
@@ -2869,6 +2892,14 @@ certificate Version Verze
|
||||
field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name}
|
||||
two cells would both be named {name} dvě buňky by se jmenovaly {name}
|
||||
a field named {name} already exists pole s názvem {name} už existuje
|
||||
Bring forward Přenést blíž
|
||||
Bring to front Přenést do popředí
|
||||
Crop image Oříznout obrázek
|
||||
Remove crop Odebrat oříznutí
|
||||
Send backward Přenést dál
|
||||
Send to back Přenést do pozadí
|
||||
Arrange image Uspořádat obrázek
|
||||
Crop Oříznout
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Dnes
|
||||
@@ -2886,3 +2917,10 @@ certificate Version Verze
|
||||
Options for this reply Možnosti této odpovědi
|
||||
Export value Exportní hodnota
|
||||
Show Page Edge Shadows Zobrazovat stíny okrajů stránek
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Hledat…
|
||||
Image pasted Obrázek vložen
|
||||
No image on clipboard to paste Ve schránce není obrázek k vložení
|
||||
Pinned {tool} to Home {tool} připnuto na úvodní stránku
|
||||
Unpinned {tool} from Home {tool} odepnuto z úvodní stránky
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -9,6 +9,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menü
|
||||
File Datei
|
||||
Edit Bearbeiten
|
||||
@@ -2516,6 +2520,26 @@ animation effects Off Aus
|
||||
Make properties default Eigenschaften als Standard festlegen
|
||||
Pages selected Ausgewählte Seiten
|
||||
Page properties Seiteneigenschaften
|
||||
Clear search Suche löschen
|
||||
Pin Anheften
|
||||
Pin a tool… Werkzeug anheften…
|
||||
Pin to Home An Start anheften
|
||||
Pin tools to Home Werkzeuge an Start anheften
|
||||
Pin your favorite tools here for quick access. Heften Sie Ihre bevorzugten Werkzeuge hier an, um schnell darauf zuzugreifen.
|
||||
Pinned Angeheftet
|
||||
Pinned Tools Angeheftete Werkzeuge
|
||||
Search tools… Werkzeuge suchen…
|
||||
Select tools to display on your Home page for quick access. Wählen Sie Werkzeuge für den Schnellzugriff auf Ihrer Startseite aus.
|
||||
Unpin from Home Von Start lösen
|
||||
Unpin tool Werkzeug lösen
|
||||
Transform image Bild transformieren
|
||||
Select Tool Auswahl-Werkzeug
|
||||
Hand Tool Hand-Werkzeug
|
||||
Marquee Zoom Auswahlrahmen-Zoom
|
||||
Paste Einfügen
|
||||
Edit this PDF Dieses PDF bearbeiten
|
||||
Export this PDF Dieses PDF exportieren
|
||||
Find text or tools Text oder Werkzeuge suchen
|
||||
Create & Edit Erstellen & Bearbeiten
|
||||
Review & Compare Prüfen & Vergleichen
|
||||
Forms & Signatures Formulare & Signaturen
|
||||
@@ -2563,6 +2587,13 @@ animation effects Off Aus
|
||||
field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name}
|
||||
two cells would both be named {name} zwei Zellen hätten beide den Namen {name}
|
||||
a field named {name} already exists ein Feld namens {name} ist bereits vorhanden
|
||||
Arrange image Bild anordnen
|
||||
Bring forward Nach vorne bringen
|
||||
Bring to front In den Vordergrund
|
||||
Crop Zuschneiden
|
||||
Remove crop Zuschnitt entfernen
|
||||
Send backward Nach hinten senden
|
||||
Send to back In den Hintergrund
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Heute
|
||||
@@ -2580,3 +2611,10 @@ animation effects Off Aus
|
||||
Options for this reply Optionen für diese Antwort
|
||||
Export value Exportwert
|
||||
Show Page Edge Shadows Schatten an Seitenrändern anzeigen
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Suchen…
|
||||
Image pasted Bild eingefügt
|
||||
No image on clipboard to paste Kein Bild in der Zwischenablage zum Einfügen
|
||||
Pinned {tool} to Home {tool} an Start angeheftet
|
||||
Unpinned {tool} from Home {tool} von Start gelöst
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2,6 +2,11 @@
|
||||
# Partial, clean-room initial contribution. Entries not yet translated fall back to English.
|
||||
# Translate from English meanings; retain placeholders and trailing ellipses.
|
||||
# Greek plural forms: one (1) | other (0, 2+).
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Μενού
|
||||
File Αρχείο
|
||||
Edit Επεξεργασία
|
||||
@@ -2521,6 +2526,26 @@ animation effects Off Ανενεργά
|
||||
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
|
||||
Pages selected Επιλεγμένες σελίδες
|
||||
Page properties Ιδιότητες σελίδας
|
||||
Clear search Εκκαθάριση αναζήτησης
|
||||
Pin Καρφίτσωμα
|
||||
Pin a tool… Καρφίτσωμα εργαλείου…
|
||||
Pin to Home Καρφίτσωμα στην Αρχική
|
||||
Pin tools to Home Καρφίτσωμα εργαλείων στην Αρχική
|
||||
Pin your favorite tools here for quick access. Καρφιτσώστε τα αγαπημένα σας εργαλεία εδώ για γρήγορη πρόσβαση.
|
||||
Pinned Καρφιτσωμένο
|
||||
Pinned Tools Καρφιτσωμένα εργαλεία
|
||||
Search tools… Αναζήτηση εργαλείων…
|
||||
Select tools to display on your Home page for quick access. Επιλέξτε εργαλεία για εμφάνιση στην Αρχική σας για γρήγορη πρόσβαση.
|
||||
Unpin from Home Ξεκαρφίτσωμα από την Αρχική
|
||||
Unpin tool Ξεκαρφίτσωμα εργαλείου
|
||||
Transform image Μετασχηματισμός εικόνας
|
||||
Select Tool Εργαλείο επιλογής
|
||||
Hand Tool Εργαλείο χεριού
|
||||
Marquee Zoom Μεγέθυνση επιλεγμένης περιοχής
|
||||
Paste Επικόλληση
|
||||
Edit this PDF Επεξεργασία αυτού του PDF
|
||||
Export this PDF Εξαγωγή αυτού του PDF
|
||||
Find text or tools Εύρεση κειμένου ή εργαλείων
|
||||
Create & Edit Δημιουργία και επεξεργασία
|
||||
Review & Compare Αναθεώρηση και σύγκριση
|
||||
Forms & Signatures Φόρμες και υπογραφές
|
||||
@@ -2568,6 +2593,13 @@ animation effects Off Ανενεργά
|
||||
field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name}
|
||||
two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name}
|
||||
a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name}
|
||||
Arrange image Διάταξη εικόνας
|
||||
Bring forward Μεταφορά εμπρός
|
||||
Bring to front Μεταφορά σε πρώτο πλάνο
|
||||
Crop Περικοπή
|
||||
Remove crop Κατάργηση περικοπής
|
||||
Send backward Μεταφορά πίσω
|
||||
Send to back Μεταφορά σε δεύτερο πλάνο
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Σήμερα
|
||||
@@ -2585,3 +2617,10 @@ animation effects Off Ανενεργά
|
||||
Options for this reply Επιλογές για αυτήν την απάντηση
|
||||
Export value Τιμή εξαγωγής
|
||||
Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Αναζήτηση…
|
||||
Image pasted Η εικόνα επικολλήθηκε
|
||||
No image on clipboard to paste Δεν υπάρχει εικόνα στο πρόχειρο για επικόλληση
|
||||
Pinned {tool} to Home Το {tool} καρφιτσώθηκε στην Αρχική
|
||||
Unpinned {tool} from Home Το {tool} ξεκαρφιτσώθηκε από την Αρχική
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 490 and column 9.
|
@@ -9,6 +9,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menú
|
||||
File Archivo
|
||||
Edit Edición
|
||||
@@ -2012,9 +2016,16 @@ action wizard Start Iniciar
|
||||
Edit text Editar texto
|
||||
Load document font Cargar fuente del documento
|
||||
Move image Mover imagen
|
||||
Transform image Transformar imagen
|
||||
Rotate image Girar imagen
|
||||
Flip image Voltear imagen
|
||||
Delete image Eliminar imagen
|
||||
Bring to front Traer al frente
|
||||
Send to back Enviar al fondo
|
||||
Bring forward Traer adelante
|
||||
Send backward Enviar atrás
|
||||
Remove crop Quitar recorte
|
||||
Crop image Recortar imagen
|
||||
Add header & footer Añadir encabezado y pie de página
|
||||
Update header & footer Actualizar encabezado y pie de página
|
||||
Update watermark Actualizar marca de agua
|
||||
@@ -2679,6 +2690,25 @@ animation effects Off Desactivados
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
|
||||
Clear search Borrar búsqueda
|
||||
Pin Anclar
|
||||
Pin a tool… Anclar una herramienta…
|
||||
Pin to Home Anclar en Inicio
|
||||
Pin tools to Home Anclar herramientas en Inicio
|
||||
Pin your favorite tools here for quick access. Ancla tus herramientas favoritas aquí para un acceso rápido.
|
||||
Pinned Anclado
|
||||
Pinned Tools Herramientas ancladas
|
||||
Search tools… Buscar herramientas…
|
||||
Select tools to display on your Home page for quick access. Selecciona las herramientas que se mostrarán en la página de Inicio para un acceso rápido.
|
||||
Unpin from Home Desanclar de Inicio
|
||||
Unpin tool Desanclar herramienta
|
||||
Select Tool Herramienta Selección
|
||||
Hand Tool Herramienta Mano
|
||||
Marquee Zoom Zoom por selección
|
||||
Paste Pegar
|
||||
Edit this PDF Editar este PDF
|
||||
Export this PDF Exportar este PDF
|
||||
Find text or tools Buscar texto o herramientas
|
||||
Attach a file… Adjuntar un archivo…
|
||||
Delete attachment Eliminar adjunto
|
||||
Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web
|
||||
@@ -2834,7 +2864,6 @@ theme Appearance Aspecto
|
||||
Import Importar
|
||||
Move Mover
|
||||
Duplicate Duplicar
|
||||
Paste Pegar
|
||||
Select all Seleccionar todo
|
||||
Refresh Actualizar
|
||||
Next Siguiente
|
||||
@@ -2885,6 +2914,8 @@ theme Appearance Aspecto
|
||||
field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name}
|
||||
two cells would both be named {name} dos celdas se llamarían {name}
|
||||
a field named {name} already exists ya existe un campo llamado {name}
|
||||
Arrange image Organizar imagen
|
||||
Crop Recortar
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hoy
|
||||
@@ -2902,3 +2933,10 @@ theme Appearance Aspecto
|
||||
Options for this reply Opciones de esta respuesta
|
||||
Export value Valor de exportación
|
||||
Show Page Edge Shadows Mostrar sombras en los bordes de las páginas
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Buscar…
|
||||
Image pasted Imagen pegada
|
||||
No image on clipboard to paste No hay ninguna imagen en el portapapeles para pegar
|
||||
Pinned {tool} to Home {tool} fijado en Inicio
|
||||
Unpinned {tool} from Home {tool} desfijado de Inicio
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -19,6 +19,10 @@
|
||||
# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins
|
||||
# compound words.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu منو
|
||||
File فایل
|
||||
Edit ویرایش
|
||||
@@ -2816,3 +2820,10 @@ theme Appearance ظاهر
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply گزینههای این پاسخ
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… جستجو…
|
||||
Image pasted تصویر چسبانده شد
|
||||
No image on clipboard to paste تصویری در حافظهٔ موقت برای چسباندن نیست
|
||||
Pinned {tool} to Home {tool} به خانه سنجاق شد
|
||||
Unpinned {tool} from Home {tool} از خانه جدا شد
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -16,6 +16,11 @@
|
||||
# Labels use sentence case. Product and technology names (PdfCraft, PDF, OCR, JavaScript ...)
|
||||
# stay in Latin letters; the trailing "…" of dialog commands is kept. Calendar headings use the
|
||||
# nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`).
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Valikko
|
||||
File Tiedosto
|
||||
Edit Muokkaa
|
||||
@@ -2824,6 +2829,33 @@ theme Appearance Teema
|
||||
field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name}
|
||||
two cells would both be named {name} kahdella solulla olisi sama nimi {name}
|
||||
a field named {name} already exists kenttä nimeltä {name} on jo olemassa
|
||||
Bring forward Tuo eteenpäin
|
||||
Bring to front Tuo etualalle
|
||||
Crop image Rajaa kuva
|
||||
Remove crop Poista rajaus
|
||||
Send backward Vie taaksepäin
|
||||
Send to back Vie taustalle
|
||||
Clear search Tyhjennä haku
|
||||
Edit this PDF Muokkaa tätä PDF-tiedostoa
|
||||
Export this PDF Vie tämä PDF-tiedosto
|
||||
Find text or tools Etsi tekstiä tai työkaluja
|
||||
Hand Tool Käsityökalu
|
||||
Marquee Zoom Alueen zoomaus
|
||||
Pin Kiinnitä
|
||||
Pin a tool… Kiinnitä työkalu…
|
||||
Pin to Home Kiinnitä Aloitus-näkymään
|
||||
Pin tools to Home Kiinnitä työkaluja Aloitus-näkymään
|
||||
Pin your favorite tools here for quick access. Kiinnitä suosikkityökalusi tähän nopeaa käyttöä varten.
|
||||
Pinned Kiinnitetty
|
||||
Pinned Tools Kiinnitetyt työkalut
|
||||
Search tools… Etsi työkaluja…
|
||||
Select Tool Valintatyökalu
|
||||
Select tools to display on your Home page for quick access. Valitse Aloitus-sivulla näytettävät työkalut nopeaa käyttöä varten.
|
||||
Transform image Muunna kuvaa
|
||||
Unpin from Home Irrota Aloitus-näkymästä
|
||||
Unpin tool Irrota työkalu
|
||||
Arrange image Järjestä kuva
|
||||
Crop Rajaa
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Tänään
|
||||
@@ -2847,3 +2879,10 @@ theme Appearance Teema
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply Tämän vastauksen asetukset
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Hae…
|
||||
Image pasted Kuva liitetty
|
||||
No image on clipboard to paste Leikepöydällä ei ole liitettävää kuvaa
|
||||
Pinned {tool} to Home {tool} kiinnitetty aloitussivulle
|
||||
Unpinned {tool} from Home {tool} irrotettu aloitussivulta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 47.
|
@@ -9,6 +9,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menu
|
||||
File Fichier
|
||||
Edit Édition
|
||||
@@ -2515,6 +2519,26 @@ animation effects Off Désactivés
|
||||
Make properties default Définir comme propriétés par défaut
|
||||
Pages selected Pages sélectionnées
|
||||
Page properties Propriétés de la page
|
||||
Clear search Effacer la recherche
|
||||
Pin Épingler
|
||||
Pin a tool… Épingler un outil…
|
||||
Pin to Home Épingler à l'Accueil
|
||||
Pin tools to Home Épingler des outils à l'Accueil
|
||||
Pin your favorite tools here for quick access. Épinglez vos outils préférés ici pour un accès rapide.
|
||||
Pinned Épinglé
|
||||
Pinned Tools Outils épinglés
|
||||
Search tools… Rechercher des outils…
|
||||
Select tools to display on your Home page for quick access. Sélectionnez les outils à afficher sur votre page d'Accueil pour un accès rapide.
|
||||
Unpin from Home Désépingler de l'Accueil
|
||||
Unpin tool Désépingler l'outil
|
||||
Transform image Transformer l'image
|
||||
Select Tool Outil Sélection
|
||||
Hand Tool Outil Main
|
||||
Marquee Zoom Zoom par sélection
|
||||
Paste Coller
|
||||
Edit this PDF Modifier ce PDF
|
||||
Export this PDF Exporter ce PDF
|
||||
Find text or tools Rechercher du texte ou des outils
|
||||
Create & Edit Créer et modifier
|
||||
Review & Compare Réviser et comparer
|
||||
Forms & Signatures Formulaires et signatures
|
||||
@@ -2632,7 +2656,6 @@ animation effects Off Désactivés
|
||||
Page {} has additional actions La page {} contient des actions supplémentaires
|
||||
Panel layout could not be restored: {error} Impossible de restaurer la disposition des panneaux : {error}
|
||||
Panel move was not applied: {error} Le déplacement du panneau n'a pas été appliqué : {error}
|
||||
Paste Coller
|
||||
PdfCraft can't check this signature yet: {}. PdfCraft ne peut pas encore contrôler cette signature : {}.
|
||||
Previous Précédent
|
||||
Previous (⇧⌘G) Précédent (⇧⌘G)
|
||||
@@ -2828,6 +2851,13 @@ certificate Version Version
|
||||
field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name}
|
||||
two cells would both be named {name} deux cellules porteraient le nom {name}
|
||||
a field named {name} already exists un champ nommé {name} existe déjà
|
||||
Arrange image Organiser l'image
|
||||
Bring forward Avancer
|
||||
Bring to front Mettre au premier plan
|
||||
Crop Recadrer
|
||||
Remove crop Supprimer le recadrage
|
||||
Send backward Reculer
|
||||
Send to back Mettre à l'arrière-plan
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Aujourd'hui
|
||||
@@ -2845,3 +2875,10 @@ certificate Version Version
|
||||
Options for this reply Options de cette réponse
|
||||
Export value Valeur d’exportation
|
||||
Show Page Edge Shadows Afficher les ombres des bords des pages
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Rechercher…
|
||||
Image pasted Image collée
|
||||
No image on clipboard to paste Aucune image à coller dans le presse-papiers
|
||||
Pinned {tool} to Home {tool} épinglé à l’Accueil
|
||||
Unpinned {tool} from Home {tool} désépinglé de l’Accueil
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2,6 +2,11 @@
|
||||
# context <TAB> English source <TAB> translation
|
||||
# Clean-room translation written from the English source's meaning.
|
||||
# License: MIT OR Apache-2.0.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu मेनू
|
||||
File फ़ाइल
|
||||
Edit संपादित करें
|
||||
@@ -2786,6 +2791,31 @@ theme Appearance स्वरूप
|
||||
field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name}
|
||||
two cells would both be named {name} दो सेल का नाम {name} हो जाएगा
|
||||
a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है
|
||||
Clear search खोज साफ़ करें
|
||||
Edit this PDF इस PDF को संपादित करें
|
||||
Export this PDF इस PDF को निर्यात करें
|
||||
Find text or tools पाठ या उपकरण ढूंढें
|
||||
Hand Tool हैंड टूल
|
||||
Marquee Zoom मार्की ज़ूम
|
||||
Pin पिन करें
|
||||
Pin a tool… उपकरण पिन करें…
|
||||
Pin to Home होम पर पिन करें
|
||||
Pin tools to Home होम पर उपकरण पिन करें
|
||||
Pin your favorite tools here for quick access. त्वरित पहुँच के लिए अपने पसंदीदा उपकरण यहाँ पिन करें।
|
||||
Pinned पिन किया गया
|
||||
Pinned Tools पिन किए गए उपकरण
|
||||
Search tools… उपकरण खोजें…
|
||||
Select Tool चयन उपकरण
|
||||
Select tools to display on your Home page for quick access. त्वरित पहुँच के लिए अपने होम पेज पर प्रदर्शित करने हेतु उपकरण चुनें।
|
||||
Unpin from Home होम से अनपिन करें
|
||||
Unpin tool उपकरण अनपिन करें
|
||||
Arrange image छवि व्यवस्थित करें
|
||||
Bring forward आगे लाएँ
|
||||
Bring to front सबसे आगे लाएँ
|
||||
Crop क्रॉप करें
|
||||
Remove crop क्रॉप हटाएँ
|
||||
Send backward पीछे भेजें
|
||||
Send to back सबसे पीछे भेजें
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today आज
|
||||
@@ -2809,3 +2839,10 @@ theme Appearance स्वरूप
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply इस जवाब के विकल्प
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… खोजें…
|
||||
Image pasted छवि चिपकाई गई
|
||||
No image on clipboard to paste चिपकाने के लिए क्लिपबोर्ड में कोई छवि नहीं है
|
||||
Pinned {tool} to Home {tool} को होम पर पिन किया गया
|
||||
Unpinned {tool} from Home {tool} को होम से अनपिन किया गया
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 875 and column 12.
|
@@ -9,6 +9,11 @@
|
||||
# consulted or copied; add entries the same way.
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menü
|
||||
File Fájl
|
||||
Edit Szerkesztés
|
||||
@@ -2514,6 +2519,26 @@ animation effects Off Ki
|
||||
Make properties default Tulajdonságok beállítása alapértelmezettként
|
||||
Pages selected Kijelölt oldalak
|
||||
Page properties Oldaltulajdonságok
|
||||
Clear search Keresés törlése
|
||||
Pin Rögzítés
|
||||
Pin a tool… Eszköz rögzítése…
|
||||
Pin to Home Rögzítés a Kezdőlapra
|
||||
Pin tools to Home Eszközök rögzítése a Kezdőlapra
|
||||
Pin your favorite tools here for quick access. Rögzítse ide kedvenc eszközeit a gyors eléréshez.
|
||||
Pinned Rögzítve
|
||||
Pinned Tools Rögzített eszközök
|
||||
Search tools… Eszközök keresése…
|
||||
Select tools to display on your Home page for quick access. Válassza ki a Kezdőlapon megjelenítendő eszközöket a gyors eléréshez.
|
||||
Unpin from Home Rögzítés feloldása a Kezdőlapról
|
||||
Unpin tool Eszköz rögzítésének feloldása
|
||||
Transform image Kép transzformálása
|
||||
Select Tool Kijelölő eszköz
|
||||
Hand Tool Kéz eszköz
|
||||
Marquee Zoom Kijelölő nagyítás
|
||||
Paste Beillesztés
|
||||
Edit this PDF Ezen PDF szerkesztése
|
||||
Export this PDF Ezen PDF exportálása
|
||||
Find text or tools Szöveg vagy eszközök keresése
|
||||
Create & Edit Létrehozás és szerkesztés
|
||||
Review & Compare Áttekintés és összehasonlítás
|
||||
Forms & Signatures Űrlapok és aláírások
|
||||
@@ -2561,6 +2586,13 @@ animation effects Off Ki
|
||||
field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name}
|
||||
two cells would both be named {name} két cella neve is {name} lenne
|
||||
a field named {name} already exists már van {name} nevű mező
|
||||
Arrange image Kép elrendezése
|
||||
Bring forward Előrébb hozás
|
||||
Bring to front Előrehozás
|
||||
Crop Körülvágás
|
||||
Remove crop Körülvágás eltávolítása
|
||||
Send backward Hátrébb küldés
|
||||
Send to back Hátraküldés
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Ma
|
||||
@@ -2578,3 +2610,10 @@ animation effects Off Ki
|
||||
Options for this reply A válasz beállításai
|
||||
Export value Exportérték
|
||||
Show Page Edge Shadows Oldalszéli árnyékok megjelenítése
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Keresés…
|
||||
Image pasted Kép beillesztve
|
||||
No image on clipboard to paste Nincs beilleszthető kép a vágólapon
|
||||
Pinned {tool} to Home {tool} rögzítve a kezdőlapra
|
||||
Unpinned {tool} from Home {tool} leválasztva a kezdőlapról
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -3,6 +3,11 @@
|
||||
# Clean-room translations of PdfCraft's English UI labels. Use standard Indonesian computing
|
||||
# terms; keep product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word...) unchanged.
|
||||
# Indonesian does not inflect nouns for number, so @plural entries list one form.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menu
|
||||
Edit Edit
|
||||
Pages Halaman
|
||||
@@ -2787,6 +2792,31 @@ theme Appearance Tampilan
|
||||
field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name}
|
||||
two cells would both be named {name} dua sel akan sama-sama bernama {name}
|
||||
a field named {name} already exists kolom bernama {name} sudah ada
|
||||
Clear search Bersihkan pencarian
|
||||
Edit this PDF Edit PDF ini
|
||||
Export this PDF Ekspor PDF ini
|
||||
Find text or tools Temukan teks atau alat
|
||||
Hand Tool Alat Tangan
|
||||
Marquee Zoom Zoom Bidang
|
||||
Pin Sematkan
|
||||
Pin a tool… Sematkan alat…
|
||||
Pin to Home Sematkan ke Beranda
|
||||
Pin tools to Home Sematkan alat ke Beranda
|
||||
Pin your favorite tools here for quick access. Sematkan alat favorit Anda di sini untuk akses cepat.
|
||||
Pinned Disematkan
|
||||
Pinned Tools Alat yang Disematkan
|
||||
Search tools… Cari alat…
|
||||
Select Tool Alat Pemilih
|
||||
Select tools to display on your Home page for quick access. Pilih alat untuk ditampilkan di halaman Beranda Anda untuk akses cepat.
|
||||
Unpin from Home Lepas sematan dari Beranda
|
||||
Unpin tool Lepas sematan alat
|
||||
Arrange image Atur gambar
|
||||
Bring forward Maju Selangkah
|
||||
Bring to front Bawa ke Depan
|
||||
Crop Pangkas
|
||||
Remove crop Hapus pangkasan
|
||||
Send backward Mundur Selangkah
|
||||
Send to back Kirim ke Belakang
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hari ini
|
||||
@@ -2810,3 +2840,10 @@ theme Appearance Tampilan
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply Opsi untuk balasan ini
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Cari…
|
||||
Image pasted Gambar ditempel
|
||||
No image on clipboard to paste Tidak ada gambar di papan klip untuk ditempel
|
||||
Pinned {tool} to Home {tool} disematkan ke Beranda
|
||||
Unpinned {tool} from Home {tool} dilepas dari Beranda
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 485 and column 12.
|
@@ -15,6 +15,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menu
|
||||
File File
|
||||
Edit Modifica
|
||||
@@ -2727,6 +2731,26 @@ certificate Key usage Utilizzo chiave
|
||||
certificate Self-signed Autofirmato
|
||||
certificate SHA-1 digest Impronta SHA-1
|
||||
certificate SHA-256 digest Impronta SHA-256
|
||||
Clear search Cancella ricerca
|
||||
Pin Fissa
|
||||
Pin a tool… Fissa uno strumento…
|
||||
Pin to Home Fissa sulla Home
|
||||
Pin tools to Home Fissa strumenti sulla Home
|
||||
Pin your favorite tools here for quick access. Fissa qui i tuoi strumenti preferiti per un accesso rapido.
|
||||
Pinned Fissato
|
||||
Pinned Tools Strumenti fissati
|
||||
Search tools… Cerca strumenti…
|
||||
Select tools to display on your Home page for quick access. Seleziona gli strumenti da visualizzare nella pagina Home per un acceso rapido.
|
||||
Unpin from Home Rimuovi da Home
|
||||
Unpin tool Sblocca strumento dalla Home
|
||||
Transform image Trasforma immagine
|
||||
Select Tool Strumento Selezione
|
||||
Hand Tool Strumento Mano
|
||||
Marquee Zoom Ingrandimento selettivo
|
||||
Paste Incolla
|
||||
Edit this PDF Modifica questo PDF
|
||||
Export this PDF Esporta questo PDF
|
||||
Find text or tools Trova testo o strumenti
|
||||
Create & Edit Crea e modifica
|
||||
Review & Compare Revisione e confronto
|
||||
Forms & Signatures Moduli e firme
|
||||
@@ -2794,6 +2818,13 @@ certificate SHA-256 digest Impronta SHA-256
|
||||
field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name}
|
||||
two cells would both be named {name} due celle si chiamerebbero entrambe {name}
|
||||
a field named {name} already exists esiste già un campo chiamato {name}
|
||||
Arrange image Disponi immagine
|
||||
Bring forward Porta avanti
|
||||
Bring to front Porta in primo piano
|
||||
Crop Ritaglia
|
||||
Remove crop Rimuovi ritaglio
|
||||
Send backward Porta indietro
|
||||
Send to back Porta in secondo piano
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Oggi
|
||||
@@ -2811,3 +2842,10 @@ certificate SHA-256 digest Impronta SHA-256
|
||||
Options for this reply Opzioni per questa risposta
|
||||
Export value Valore di esportazione
|
||||
Show Page Edge Shadows Mostra le ombre ai bordi delle pagine
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Cerca…
|
||||
Image pasted Immagine incollata
|
||||
No image on clipboard to paste Nessuna immagine negli appunti da incollare
|
||||
Pinned {tool} to Home {tool} fissato alla Home
|
||||
Unpinned {tool} from Home {tool} rimosso dai preferiti della Home
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -15,6 +15,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu メニュー
|
||||
File ファイル
|
||||
Edit 編集
|
||||
@@ -2515,6 +2519,26 @@ animation effects Off オフ
|
||||
Make properties default プロパティをデフォルトに設定
|
||||
Pages selected 選択したページ
|
||||
Page properties ページのプロパティ
|
||||
Clear search 検索をクリア
|
||||
Pin ピン留め
|
||||
Pin a tool… ツールをピン留め…
|
||||
Pin to Home ホームにピン留め
|
||||
Pin tools to Home ツールをホームにピン留め
|
||||
Pin your favorite tools here for quick access. よく使うツールをここにピン留めして、すばやくアクセスできるようにします。
|
||||
Pinned ピン留め済み
|
||||
Pinned Tools ピン留めしたツール
|
||||
Search tools… ツールを検索…
|
||||
Select tools to display on your Home page for quick access. すばやくアクセスできるように、ホームに表示するツールを選択します。
|
||||
Unpin from Home ホームからピン留めを解除
|
||||
Unpin tool ツールのピン留めを解除
|
||||
Transform image 画像を変換
|
||||
Select Tool 選択ツール
|
||||
Hand Tool 手のひらツール
|
||||
Marquee Zoom 範囲指定ズーム
|
||||
Paste 貼り付け
|
||||
Edit this PDF この PDF を編集
|
||||
Export this PDF この PDF を書き出す
|
||||
Find text or tools テキストまたはツールを検索
|
||||
Create & Edit 作成と編集
|
||||
Review & Compare レビューと比較
|
||||
Forms & Signatures フォームと署名
|
||||
@@ -2562,6 +2586,13 @@ animation effects Off オフ
|
||||
field names can't contain a period: {name} フィールド名にピリオドは使えません: {name}
|
||||
two cells would both be named {name} 2 つのセルが同じ名前 {name} になります
|
||||
a field named {name} already exists {name} という名前のフィールドは既にあります
|
||||
Arrange image 画像を配置
|
||||
Bring forward 前面へ移動
|
||||
Bring to front 最前面へ移動
|
||||
Crop 切り抜き
|
||||
Remove crop 切り抜きを解除
|
||||
Send backward 背面へ移動
|
||||
Send to back 最背面へ移動
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今日
|
||||
@@ -2579,3 +2610,10 @@ animation effects Off オフ
|
||||
Options for this reply この返信のオプション
|
||||
Export value 書き出し値
|
||||
Show Page Edge Shadows ページ端の影を表示
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… 検索…
|
||||
Image pasted 画像を貼り付けました
|
||||
No image on clipboard to paste 貼り付ける画像がクリップボードにありません
|
||||
Pinned {tool} to Home {tool} をホームにピン留めしました
|
||||
Unpinned {tool} from Home {tool} のホームへのピン留めを解除しました
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1,3 +1,7 @@
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Мәзір
|
||||
File Файл
|
||||
Edit Түзету
|
||||
@@ -2513,6 +2517,26 @@ animation effects Off Өшірулі
|
||||
Make properties default Қасиеттерді әдепкі ету
|
||||
Pages selected Таңдалған беттер
|
||||
Page properties Бет қасиеттері
|
||||
Clear search Іздеуді тазалау
|
||||
Pin Бекіту
|
||||
Pin a tool… Құралды бекіту…
|
||||
Pin to Home Басты бетке бекіту
|
||||
Pin tools to Home Құралдарды басты бетке бекіту
|
||||
Pin your favorite tools here for quick access. Жылдам қол жеткізу үшін таңдаулы құралдарды осында бекітіңіз.
|
||||
Pinned Бекітілген
|
||||
Pinned Tools Бекітілген құралдар
|
||||
Search tools… Құралдарды іздеу…
|
||||
Select tools to display on your Home page for quick access. Жылдам қол жеткізу үшін басты бетте көрсетілетін құралдарды таңдаңыз.
|
||||
Unpin from Home Басты беттен алып тастау
|
||||
Unpin tool Құралды бекітуден шығару
|
||||
Transform image Кескінді түрлендіру
|
||||
Select Tool Таңдау құралы
|
||||
Hand Tool Қол құралы
|
||||
Marquee Zoom Аймақты масштабтау
|
||||
Paste Қою
|
||||
Edit this PDF Осы PDF файлын түзету
|
||||
Export this PDF Осы PDF файлын экспорттау
|
||||
Find text or tools Мәтінді немесе құралдарды табу
|
||||
Create & Edit Жасау және өңдеу
|
||||
Review & Compare Шолу және салыстыру
|
||||
Forms & Signatures Пішіндер және қолтаңбалар
|
||||
@@ -2560,6 +2584,13 @@ animation effects Off Өшірулі
|
||||
field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name}
|
||||
two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді
|
||||
a field named {name} already exists {name} атты өріс бар
|
||||
Arrange image Суретті реттеу
|
||||
Bring forward Алға жылжыту
|
||||
Bring to front Алдыңғы планға шығару
|
||||
Crop Қию
|
||||
Remove crop Қиюды алып тастау
|
||||
Send backward Артқа жылжыту
|
||||
Send to back Артқы планға жіберу
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Бүгін
|
||||
@@ -2577,3 +2608,10 @@ animation effects Off Өшірулі
|
||||
Options for this reply Осы жауаптың параметрлері
|
||||
Export value Экспорт мәні
|
||||
Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Іздеу…
|
||||
Image pasted Сурет қойылды
|
||||
No image on clipboard to paste Алмасу буферінде қоятын сурет жоқ
|
||||
Pinned {tool} to Home {tool} басты бетке бекітілді
|
||||
Unpinned {tool} from Home {tool} басты беттен ажыратылды
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 885 and column 9.
|
@@ -2,6 +2,11 @@
|
||||
# context <TAB> English source <TAB> translation
|
||||
# Clean-room translation written from the English source's meaning.
|
||||
# License: MIT OR Apache-2.0.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu 메뉴
|
||||
File 파일
|
||||
Edit 편집
|
||||
@@ -2786,6 +2791,31 @@ theme Appearance 모양
|
||||
field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name}
|
||||
two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다
|
||||
a field named {name} already exists {name}(이)라는 필드가 이미 있습니다
|
||||
Clear search 검색 지우기
|
||||
Edit this PDF 이 PDF 편집
|
||||
Export this PDF 이 PDF 내보내기
|
||||
Find text or tools 텍스트 또는 도구 찾기
|
||||
Hand Tool 손 도구
|
||||
Marquee Zoom 영역 확대/축소
|
||||
Pin 고정
|
||||
Pin a tool… 도구 고정…
|
||||
Pin to Home 홈에 고정
|
||||
Pin tools to Home 홈에 도구 고정
|
||||
Pin your favorite tools here for quick access. 빠른 액세스를 위해 즐겨찾는 도구를 여기에 고정하세요.
|
||||
Pinned 고정됨
|
||||
Pinned Tools 고정된 도구
|
||||
Search tools… 도구 검색…
|
||||
Select Tool 선택 도구
|
||||
Select tools to display on your Home page for quick access. 빠른 액세스를 위해 홈 페이지에 표시할 도구를 선택하세요.
|
||||
Unpin from Home 홈에서 고정 해제
|
||||
Unpin tool 도구 고정 해제
|
||||
Arrange image 이미지 정렬
|
||||
Bring forward 앞으로 가져오기
|
||||
Bring to front 맨 앞으로 가져오기
|
||||
Crop 자르기
|
||||
Remove crop 자르기 제거
|
||||
Send backward 뒤로 보내기
|
||||
Send to back 맨 뒤로 보내기
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 오늘
|
||||
@@ -2809,3 +2839,10 @@ theme Appearance 모양
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply 이 답글의 옵션
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… 검색…
|
||||
Image pasted 이미지를 붙여넣었습니다
|
||||
No image on clipboard to paste 클립보드에 붙여넣을 이미지가 없습니다
|
||||
Pinned {tool} to Home {tool}을 홈에 고정했습니다
|
||||
Unpinned {tool} from Home {tool}의 홈 고정을 해제했습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 875 and column 12.
|
@@ -15,6 +15,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "..." of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu ເມນູ
|
||||
File ໄຟລ໌
|
||||
Edit ແກ້ໄຂ
|
||||
@@ -2451,6 +2455,13 @@ animation effects Off ປິດ
|
||||
Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ
|
||||
Pages selected ໜ້າທີ່ເລືອກ
|
||||
Page properties ຄຸນສົມບັດໜ້າ
|
||||
Arrange image ຈັດຮູບພາບ
|
||||
Bring forward ນຳມາທາງໜ້າ
|
||||
Bring to front ນຳມາໄວ້ໜ້າສຸດ
|
||||
Crop ຕັດຂອບ
|
||||
Remove crop ຍົກເລີກການຕັດຂອບ
|
||||
Send backward ສົ່ງໄປທາງຫຼັງ
|
||||
Send to back ສົ່ງໄປໄວ້ຫຼັງສຸດ
|
||||
Create & Edit ສ້າງ & ແກ້ໄຂ
|
||||
Review & Compare ກວດກາຄືນ & ປຽບທຽບ
|
||||
Forms & Signatures ຟອມ & ລາຍເຊັນ
|
||||
@@ -2481,3 +2492,33 @@ animation effects Off ປິດ
|
||||
Unchecked reads Unchecked reads
|
||||
{field} reads fields named as it runs {field} reads fields named as it runs
|
||||
Some names are built as it runs Some names are built as it runs
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… ຄົ້ນຫາ…
|
||||
Image pasted ວາງຮູບແລ້ວ
|
||||
No image on clipboard to paste ບໍ່ມີຮູບໃນຄລິບບອດເພື່ອວາງ
|
||||
Pinned {tool} to Home ປັກໝຸດ {tool} ໄວ້ໜ້າຫຼັກແລ້ວ
|
||||
Unpinned {tool} from Home ຖອນໝຸດ {tool} ຈາກໜ້າຫຼັກແລ້ວ
|
||||
|
||||
# Home tools (AI-assisted; not separately native-reviewed)
|
||||
Clear search ລ້າງການຄົ້ນຫາ
|
||||
Pin ປັກໝຸດ
|
||||
Pin a tool… ປັກໝຸດເຄື່ອງມື…
|
||||
Pin to Home ປັກໝຸດໄວ້ໜ້າຫຼັກ
|
||||
Pin tools to Home ປັກໝຸດເຄື່ອງມືໄວ້ໜ້າຫຼັກ
|
||||
Pin your favorite tools here for quick access. ປັກໝຸດເຄື່ອງມືທີ່ມັກໄວ້ທີ່ນີ້ເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ.
|
||||
Pinned ປັກໝຸດແລ້ວ
|
||||
Pinned Tools ເຄື່ອງມືທີ່ປັກໝຸດ
|
||||
Search tools… ຄົ້ນຫາເຄື່ອງມື…
|
||||
Select tools to display on your Home page for quick access. ເລືອກເຄື່ອງມືໃຫ້ສະແດງໃນໜ້າຫຼັກເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ.
|
||||
Unpin from Home ຖອນໝຸດຈາກໜ້າຫຼັກ
|
||||
Unpin tool ຖອນໝຸດເຄື່ອງມື
|
||||
Transform image ປ່ຽນຮູບຮ່າງຮູບ
|
||||
Select Tool ເຄື່ອງມືເລືອກ
|
||||
Hand Tool ເຄື່ອງມືມື
|
||||
Marquee Zoom ຊູມພື້ນທີ່ເລືອກ
|
||||
Paste ວາງ
|
||||
Edit this PDF ແກ້ໄຂ PDF ນີ້
|
||||
Export this PDF ສົ່ງອອກ PDF ນີ້
|
||||
Find text or tools ຊອກຫາຂໍ້ຄວາມ ຫຼື ເຄື່ອງມື
|
||||
Crop image ຕັດຂອບຮູບພາບ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 32.
|
@@ -4,6 +4,10 @@
|
||||
# consult the Lietuvos Respublikos terminų bankas (terminai.vlkk.lt) when selecting terminology.
|
||||
# Product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain unchanged.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Meniu
|
||||
Edit Redaguoti
|
||||
Pages Puslapiai
|
||||
@@ -2765,6 +2769,12 @@ key Space Tarpas
|
||||
Make properties default Nustatyti ypatybes kaip numatytąsias
|
||||
Pages selected Pasirinkti puslapiai
|
||||
Page properties Puslapio ypatybės
|
||||
Bring forward Perkelti į priekį
|
||||
Bring to front Iškelti į priekį
|
||||
Crop image Apkarpyti paveikslėlį
|
||||
Remove crop Pašalinti apkarpymą
|
||||
Send backward Perkelti atgal
|
||||
Send to back Nukelti į galą
|
||||
Create & Edit Kūrimas ir redagavimas
|
||||
Review & Compare Peržiūra ir palyginimas
|
||||
Forms & Signatures Formos ir parašai
|
||||
@@ -2773,7 +2783,6 @@ key Space Tarpas
|
||||
No tools match your search. Paieškos neatitinka joks įrankis.
|
||||
Expand all tools Išskleisti visus įrankius
|
||||
Collapse all tools Suskleisti visus įrankius
|
||||
|
||||
# AI-assisted additions for concurrently merged UI features (2026-10-15).
|
||||
# These additions have not had separate native-speaker review.
|
||||
Mouse wheel scroll speed Pelės ratuko slinkimo greitis
|
||||
@@ -2781,6 +2790,25 @@ key Space Tarpas
|
||||
The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
|
||||
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti.
|
||||
theme Appearance Išvaizda
|
||||
Clear search Išvalyti paiešką
|
||||
Pin Prisegti
|
||||
Pin a tool… Prisegti įrankį…
|
||||
Pin to Home Prisegti pradžioje
|
||||
Pin tools to Home Prisegti įrankius pradžioje
|
||||
Pin your favorite tools here for quick access. Prisekite mėgstamus įrankius čia, kad greitai pasiektumėte.
|
||||
Pinned Prisegta
|
||||
Pinned Tools Prisegti įrankiai
|
||||
Search tools… Ieškoti įrankių…
|
||||
Select tools to display on your Home page for quick access. Pasirinkite įrankius, kuriuos norite rodyti pradžios puslapyje.
|
||||
Unpin from Home Atsegti nuo pradžios
|
||||
Unpin tool Atsegti įrankį
|
||||
Transform image Transformuoti paveikslėlį
|
||||
Select Tool Pasirinkimo įrankis
|
||||
Hand Tool Rankos įrankis
|
||||
Marquee Zoom Didinti pažymėtą sritį
|
||||
Edit this PDF Redaguoti šį PDF
|
||||
Export this PDF Eksportuoti šį PDF
|
||||
Find text or tools Rasti tekstą arba įrankius
|
||||
Attach a file… Pridėti failą…
|
||||
Delete attachment Ištrinti priedą
|
||||
Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas
|
||||
@@ -2823,6 +2851,8 @@ theme Appearance Išvaizda
|
||||
a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių
|
||||
the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max}
|
||||
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0
|
||||
Arrange image Tvarkyti vaizdą
|
||||
Crop Apkarpyti
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Šiandien
|
||||
@@ -2840,3 +2870,10 @@ theme Appearance Išvaizda
|
||||
Options for this reply Šio atsakymo parinktys
|
||||
Export value Eksporto reikšmė
|
||||
Show Page Edge Shadows Rodyti puslapių kraštų šešėlius
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Ieškoti…
|
||||
Image pasted Paveikslėlis įklijuotas
|
||||
No image on clipboard to paste Iškarpinėje nėra paveikslėlio įklijuoti
|
||||
Pinned {tool} to Home {tool} prisegtas pradžios puslapyje
|
||||
Unpinned {tool} from Home {tool} atsegtas nuo pradžios puslapio
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 408 and column 9.
|
@@ -7,6 +7,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Allow Tillat
|
||||
Cancel Avbryt
|
||||
Only allow this if you trust the source document. Tillat dette bare hvis du stoler på kildedokumentet.
|
||||
@@ -2724,6 +2728,13 @@ key Space Mellomrom
|
||||
# Page gaps (AI-assisted, not separately native-reviewed)
|
||||
Show gaps between pages Vis mellomrom mellom sidene
|
||||
Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke.
|
||||
Arrange image Ranger bilde
|
||||
Bring forward Flytt fremover
|
||||
Bring to front Plasser lengst frem
|
||||
Crop Beskjær
|
||||
Remove crop Fjern beskjæring
|
||||
Send backward Flytt bakover
|
||||
Send to back Plasser lengst bak
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today I dag
|
||||
@@ -2754,3 +2765,32 @@ key Space Mellomrom
|
||||
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
|
||||
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
|
||||
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Søk…
|
||||
Image pasted Bilde limt inn
|
||||
No image on clipboard to paste Ingen bilder på utklippstavlen å lime inn
|
||||
Pinned {tool} to Home Festet {tool} til Hjem
|
||||
Unpinned {tool} from Home Løsnet {tool} fra Hjem
|
||||
|
||||
# Home tools (AI-assisted; not separately native-reviewed)
|
||||
Clear search Tøm søk
|
||||
Pin Fest
|
||||
Pin a tool… Fest et verktøy…
|
||||
Pin to Home Fest til Hjem
|
||||
Pin tools to Home Fest verktøy til Hjem
|
||||
Pin your favorite tools here for quick access. Fest favorittverktøyene dine her for rask tilgang.
|
||||
Pinned Festet
|
||||
Pinned Tools Festede verktøy
|
||||
Search tools… Søk etter verktøy…
|
||||
Select tools to display on your Home page for quick access. Velg verktøy som skal vises på hjemmesiden for rask tilgang.
|
||||
Unpin from Home Løsne fra Hjem
|
||||
Unpin tool Løsne verktøy
|
||||
Transform image Transformer bilde
|
||||
Select Tool Markeringsverktøy
|
||||
Hand Tool Håndverktøy
|
||||
Marquee Zoom Områdezoom
|
||||
Edit this PDF Rediger denne PDF-en
|
||||
Export this PDF Eksporter denne PDF-en
|
||||
Find text or tools Finn tekst eller verktøy
|
||||
Crop image Beskjær bilde
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -15,6 +15,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menu
|
||||
File Plik
|
||||
Edit Edycja
|
||||
@@ -2491,6 +2495,26 @@ animation effects Off Wyłączone
|
||||
Make properties default Ustaw właściwości jako domyślne
|
||||
Pages selected Zaznaczone strony
|
||||
Page properties Właściwości strony
|
||||
Clear search Wyczyść wyszukiwanie
|
||||
Pin Przypnij
|
||||
Pin a tool… Przypnij narzędzie…
|
||||
Pin to Home Przypnij do strony głównej
|
||||
Pin tools to Home Przypnij narzędzia do strony głównej
|
||||
Pin your favorite tools here for quick access. Przypnij tutaj ulubione narzędzia, aby mieć do nich szybki dostęp.
|
||||
Pinned Przypięte
|
||||
Pinned Tools Przypięte narzędzia
|
||||
Search tools… Szukaj narzędzi…
|
||||
Select tools to display on your Home page for quick access. Wybierz narzędzia do wyświetlenia na stronie głównej, aby mieć do nich szybki dostęp.
|
||||
Unpin from Home Odepnij od strony głównej
|
||||
Unpin tool Odepnij narzędzie
|
||||
Transform image Przekształć obraz
|
||||
Select Tool Narzędzie Zaznaczanie
|
||||
Hand Tool Narzędzie Rączka
|
||||
Marquee Zoom Powiększ zaznaczony obszar
|
||||
Paste Wklej
|
||||
Edit this PDF Edytuj ten plik PDF
|
||||
Export this PDF Eksportuj ten plik PDF
|
||||
Find text or tools Znajdź tekst lub narzędzia
|
||||
Create & Edit Tworzenie i edycja
|
||||
Review & Compare Recenzja i porównanie
|
||||
Forms & Signatures Formularze i podpisy
|
||||
@@ -2658,6 +2682,13 @@ animation effects Off Wyłączone
|
||||
field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name}
|
||||
two cells would both be named {name} dwie komórki miałyby nazwę {name}
|
||||
a field named {name} already exists pole o nazwie {name} już istnieje
|
||||
Arrange image Rozmieść obraz
|
||||
Bring forward Przesuń do przodu
|
||||
Bring to front Przesuń na wierzch
|
||||
Crop Przytnij
|
||||
Remove crop Usuń przycięcie
|
||||
Send backward Przesuń do tyłu
|
||||
Send to back Przesuń pod spód
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Dzisiaj
|
||||
@@ -2675,3 +2706,10 @@ animation effects Off Wyłączone
|
||||
Options for this reply Opcje tej odpowiedzi
|
||||
Export value Wartość eksportowa
|
||||
Show Page Edge Shadows Pokaż cienie krawędzi stron
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Szukaj…
|
||||
Image pasted Wklejono obraz
|
||||
No image on clipboard to paste Brak obrazu do wklejenia w schowku
|
||||
Pinned {tool} to Home Przypięto {tool} do strony głównej
|
||||
Unpinned {tool} from Home Odpięto {tool} od strony głównej
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menu
|
||||
File Arquivo
|
||||
Rotation: Rotação:
|
||||
@@ -2529,6 +2533,25 @@ animation effects Off Desativados
|
||||
Make properties default Tornar propriedades padrão
|
||||
Pages selected Páginas selecionadas
|
||||
Page properties Propriedades da página
|
||||
Clear search Limpar pesquisa
|
||||
Pin Fixar
|
||||
Pin a tool… Fixar uma ferramenta…
|
||||
Pin to Home Fixar no Início
|
||||
Pin tools to Home Fixar ferramentas no Início
|
||||
Pin your favorite tools here for quick access. Fixe suas ferramentas favoritas aqui para acesso rápido.
|
||||
Pinned Fixado
|
||||
Pinned Tools Ferramentas fixadas
|
||||
Search tools… Pesquisar ferramentas…
|
||||
Select tools to display on your Home page for quick access. Selecione ferramentas para exibir na página Início para acesso rápido.
|
||||
Unpin from Home Desafixar do Início
|
||||
Unpin tool Desafixar ferramenta
|
||||
Transform image Transformar imagem
|
||||
Select Tool Ferramenta Seleção
|
||||
Hand Tool Ferramenta Mão
|
||||
Marquee Zoom Zoom por seleção
|
||||
Edit this PDF Editar este PDF
|
||||
Export this PDF Exportar este PDF
|
||||
Find text or tools Localizar texto ou ferramentas
|
||||
Create & Edit Criar e editar
|
||||
Review & Compare Revisar e comparar
|
||||
Forms & Signatures Formulários e assinaturas
|
||||
@@ -2847,6 +2870,14 @@ theme Appearance Aparência
|
||||
a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas
|
||||
the first row number can be at most {max} o número da primeira linha pode ser no máximo {max}
|
||||
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0
|
||||
Bring forward Avançar
|
||||
Bring to front Trazer para a frente
|
||||
Crop image Cortar imagem
|
||||
Remove crop Remover corte
|
||||
Send backward Recuar
|
||||
Send to back Enviar para trás
|
||||
Arrange image Organizar imagem
|
||||
Crop Cortar
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hoje
|
||||
@@ -2864,3 +2895,10 @@ theme Appearance Aparência
|
||||
Options for this reply Opções desta resposta
|
||||
Export value Valor de exportação
|
||||
Show Page Edge Shadows Mostrar sombras nas bordas das páginas
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Pesquisar…
|
||||
Image pasted Imagem colada
|
||||
No image on clipboard to paste Nenhuma imagem na área de transferência para colar
|
||||
Pinned {tool} to Home {tool} fixado no Início
|
||||
Unpinned {tool} from Home {tool} desafixado do Início
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -9,6 +9,10 @@
|
||||
# the trailing "…" of dialog commands is kept. Commands use the noun form ("Salvare", "Deschidere…"),
|
||||
# messages address the user formally ("Alegeți", "dvs.").
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Meniu
|
||||
Edit Editare
|
||||
Pages Pagini
|
||||
@@ -2815,6 +2819,33 @@ theme Appearance Aspect
|
||||
field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name}
|
||||
two cells would both be named {name} două celule s-ar numi amândouă {name}
|
||||
a field named {name} already exists există deja un câmp numit {name}
|
||||
Bring forward Aduce mai în față
|
||||
Bring to front Aduce în prim-plan
|
||||
Crop image Decupare imagine
|
||||
Remove crop Elimină decuparea
|
||||
Send backward Trimite mai în spate
|
||||
Send to back Trimite în fundal
|
||||
Clear search Golește căutarea
|
||||
Edit this PDF Editează acest PDF
|
||||
Export this PDF Exportă acest PDF
|
||||
Find text or tools Găsește text sau instrumente
|
||||
Hand Tool Instrumentul Mână
|
||||
Marquee Zoom Zoom cadru
|
||||
Pin Fixează
|
||||
Pin a tool… Fixează un instrument…
|
||||
Pin to Home Fixează la Pagina de pornire
|
||||
Pin tools to Home Fixează instrumente la Pagina de pornire
|
||||
Pin your favorite tools here for quick access. Fixează instrumentele preferate aici pentru acces rapid.
|
||||
Pinned Fixat
|
||||
Pinned Tools Instrumente fixate
|
||||
Search tools… Caută instrumente…
|
||||
Select Tool Instrumentul Selecție
|
||||
Select tools to display on your Home page for quick access. Selectează instrumentele de afișat pe Pagina de pornire pentru acces rapid.
|
||||
Transform image Transformare imagine
|
||||
Unpin from Home Anulează fixarea la Pagina de pornire
|
||||
Unpin tool Anulează fixarea instrumentului
|
||||
Arrange image Aranjează imaginea
|
||||
Crop Decupare
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Astăzi
|
||||
@@ -2838,3 +2869,10 @@ theme Appearance Aspect
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply Opțiuni pentru acest răspuns
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Caută…
|
||||
Image pasted Imagine lipită
|
||||
No image on clipboard to paste Nicio imagine în clipboard de lipit
|
||||
Pinned {tool} to Home {tool} fixat pe pagina principală
|
||||
Unpinned {tool} from Home {tool} desprins de pe pagina principală
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 110.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Меню
|
||||
File Файл
|
||||
Edit Редактирование
|
||||
@@ -2428,6 +2432,26 @@ animation effects Off Выкл
|
||||
Make properties default Сделать свойства стандартными
|
||||
Pages selected Выделенные страницы
|
||||
Page properties Свойства страницы
|
||||
Clear search Очистить поиск
|
||||
Pin Закрепить
|
||||
Pin a tool… Закрепить инструмент…
|
||||
Pin to Home Закрепить на Главной
|
||||
Pin tools to Home Закрепить инструменты на Главной
|
||||
Pin your favorite tools here for quick access. Закрепите здесь избранные инструменты для быстрого доступа.
|
||||
Pinned Закреплено
|
||||
Pinned Tools Закрепленные инструменты
|
||||
Search tools… Поиск инструментов…
|
||||
Select tools to display on your Home page for quick access. Выберите инструменты для отображения на Главной странице для быстрого доступа.
|
||||
Unpin from Home Открепить от Главной
|
||||
Unpin tool Открепить инструмент
|
||||
Transform image Трансформировать изображение
|
||||
Select Tool Инструмент «Выделение»
|
||||
Hand Tool Инструмент «Рука»
|
||||
Marquee Zoom Масштаб выделением
|
||||
Paste Вставить
|
||||
Edit this PDF Редактировать этот PDF
|
||||
Export this PDF Экспортировать этот PDF
|
||||
Find text or tools Найти текст или инструменты
|
||||
Create & Edit Создание и редактирование
|
||||
Review & Compare Рецензирование и сравнение
|
||||
Forms & Signatures Формы и подписи
|
||||
@@ -2475,6 +2499,13 @@ animation effects Off Выкл
|
||||
field names can't contain a period: {name} имена полей не могут содержать точку: {name}
|
||||
two cells would both be named {name} две ячейки получили бы одно имя {name}
|
||||
a field named {name} already exists поле с именем {name} уже существует
|
||||
Arrange image Упорядочить изображение
|
||||
Bring forward Переместить вперед
|
||||
Bring to front На передний план
|
||||
Crop Обрезать
|
||||
Remove crop Убрать обрезку
|
||||
Send backward Переместить назад
|
||||
Send to back На задний план
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сегодня
|
||||
@@ -2492,3 +2523,10 @@ animation effects Off Выкл
|
||||
Options for this reply Параметры этого ответа
|
||||
Export value Значение экспорта
|
||||
Show Page Edge Shadows Показывать тени по краям страниц
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Поиск…
|
||||
Image pasted Изображение вставлено
|
||||
No image on clipboard to paste В буфере обмена нет изображения для вставки
|
||||
Pinned {tool} to Home {tool} закреплён на главной
|
||||
Unpinned {tool} from Home {tool} откреплён от главной
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Meni
|
||||
File Datoteka
|
||||
Edit Urejanje
|
||||
@@ -2520,6 +2524,26 @@ animation effects Off Izklopljeni
|
||||
Make properties default Nastavi lastnosti kot privzete
|
||||
Pages selected Izbrane strani
|
||||
Page properties Lastnosti strani
|
||||
Clear search Počisti iskanje
|
||||
Pin Pripni
|
||||
Pin a tool… Pripni orodje…
|
||||
Pin to Home Pripni na domov
|
||||
Pin tools to Home Pripni orodja na domov
|
||||
Pin your favorite tools here for quick access. Tukaj pripnite svoja priljubljena orodja za hiter dostop.
|
||||
Pinned Pripeto
|
||||
Pinned Tools Pripeta orodja
|
||||
Search tools… Išči orodja…
|
||||
Select tools to display on your Home page for quick access. Izberite orodja za prikaz na domači strani za hiter dostop.
|
||||
Unpin from Home Odpni z domače strani
|
||||
Unpin tool Odpni orodje
|
||||
Transform image Preoblikuj sliko
|
||||
Select Tool Orodje za izbiro
|
||||
Hand Tool Orodje z roko
|
||||
Marquee Zoom Povečava z okvirjem
|
||||
Paste Prilepi
|
||||
Edit this PDF Uredi ta PDF
|
||||
Export this PDF Izvozi ta PDF
|
||||
Find text or tools Poišči besedilo ali orodja
|
||||
Create & Edit Ustvarjanje in urejanje
|
||||
Review & Compare Pregled in primerjava
|
||||
Forms & Signatures Obrazci in podpisi
|
||||
@@ -2567,6 +2591,13 @@ animation effects Off Izklopljeni
|
||||
field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name}
|
||||
two cells would both be named {name} dve celici bi imeli enako ime {name}
|
||||
a field named {name} already exists polje z imenom {name} že obstaja
|
||||
Arrange image Uredi sliko
|
||||
Bring forward Premakni naprej
|
||||
Bring to front Postavi v ospredje
|
||||
Crop Obreži
|
||||
Remove crop Odstrani obrezovanje
|
||||
Send backward Premakni nazaj
|
||||
Send to back Postavi v ozadje
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Danes
|
||||
@@ -2584,3 +2615,10 @@ animation effects Off Izklopljeni
|
||||
Options for this reply Možnosti tega odgovora
|
||||
Export value Izvozna vrednost
|
||||
Show Page Edge Shadows Prikaži sence robov strani
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Išči…
|
||||
Image pasted Slika prilepljena
|
||||
No image on clipboard to paste V odložišču ni slike za lepljenje
|
||||
Pinned {tool} to Home {tool} pripeto na domačo stran
|
||||
Unpinned {tool} from Home {tool} odpeto z domače strani
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menyja
|
||||
File Skedari
|
||||
Edit Redakto
|
||||
@@ -2385,7 +2389,7 @@ animation effects Off Joaktiv
|
||||
Collapse all pages Collapse all pages
|
||||
Counting words… {n} pages read Counting words… {n} pages read
|
||||
Create Link… Create Link…
|
||||
Crop Image Crop Image
|
||||
Crop Image Prit imazhin
|
||||
Current page only Current page only
|
||||
Cut Image Cut Image
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet.
|
||||
@@ -2463,7 +2467,7 @@ animation effects Off Joaktiv
|
||||
Recognize Text… Recognize Text…
|
||||
Redact Image Redact Image
|
||||
Redaction mark appearance Redaction mark appearance
|
||||
Remove Crop Remove Crop
|
||||
Remove Crop Hiq prerjen
|
||||
Remove this leaf tag from reading order and mark its direct content as an artifact Remove this leaf tag from reading order and mark its direct content as an artifact
|
||||
Resolution: Resolution:
|
||||
Save anyway Save anyway
|
||||
@@ -2541,6 +2545,32 @@ animation effects Off Joaktiv
|
||||
field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name}
|
||||
two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name}
|
||||
a field named {name} already exists ekziston tashmë një fushë me emrin {name}
|
||||
Clear search Pastro kërkimin
|
||||
Edit this PDF Redakto këtë PDF
|
||||
Export this PDF Eksporto këtë PDF
|
||||
Find text or tools Gjej tekst ose mjete
|
||||
Hand Tool Mjeti Dorë
|
||||
Marquee Zoom Zmadhim me kornizë
|
||||
Paste Ngjit
|
||||
Pin Fikso
|
||||
Pin a tool… Fikso një mjet…
|
||||
Pin to Home Fikso në Ballinë
|
||||
Pin tools to Home Fikso mjetet në Ballinë
|
||||
Pin your favorite tools here for quick access. Fiksoni mjetet tuaja të preferuara këtu për qasje të shpejtë.
|
||||
Pinned I fiksuar
|
||||
Pinned Tools Mjete të fiksuara
|
||||
Search tools… Kërko mjete…
|
||||
Select Tool Mjeti Përzgjedhje
|
||||
Select tools to display on your Home page for quick access. Zgjidhni mjetet për t'u shfaqur në faqen tuaj të Ballinës për qasje të shpejtë.
|
||||
Unpin from Home Hiq fiksimin nga Ballina
|
||||
Unpin tool Hiq fiksimin e mjetit
|
||||
Arrange image Rregullo figurën
|
||||
Bring forward Kalo përpara
|
||||
Bring to front Sille përpara
|
||||
Crop Prit
|
||||
Remove crop Hiq prerjen
|
||||
Send backward Kalo prapa
|
||||
Send to back Dërgo mbrapa
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Sot
|
||||
@@ -2564,3 +2594,10 @@ animation effects Off Joaktiv
|
||||
|
||||
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
|
||||
Options for this reply Opsionet për këtë përgjigje
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Kërko…
|
||||
Image pasted Imazhi u ngjit
|
||||
No image on clipboard to paste Nuk ka imazh në kujtesën e përkohshme për të ngjitur
|
||||
Pinned {tool} to Home {tool} u fiksua te Kreu
|
||||
Unpinned {tool} from Home {tool} u hoq nga fiksimi te Kreu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -12,6 +12,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu మెనూ
|
||||
File ఫైల్
|
||||
Edit సవరణ
|
||||
@@ -2424,6 +2428,26 @@ animation effects Off ఆఫ్
|
||||
Make properties default లక్షణాలను డిఫాల్ట్గా చేయి
|
||||
Pages selected ఎంచుకున్న పేజీలు
|
||||
Page properties పేజీ లక్షణాలు
|
||||
Clear search శోధనను తీసివేయి
|
||||
Pin పిన్ చేయి
|
||||
Pin a tool… ఒక సాధనాన్ని పిన్ చేయి…
|
||||
Pin to Home హోమ్కు పిన్ చేయి
|
||||
Pin tools to Home సాధనాలను హోమ్కు పిన్ చేయి
|
||||
Pin your favorite tools here for quick access. త్వరిత ప్రాప్యత కోసం మీకు ఇష్టమైన సాధనాలను ఇక్కడ పిన్ చేయండి.
|
||||
Pinned పిన్ చేయబడింది
|
||||
Pinned Tools పిన్ చేసిన సాధనాలు
|
||||
Search tools… సాధనాలను శోధించండి…
|
||||
Select tools to display on your Home page for quick access. త్వరిత ప్రాప్యత కోసం మీ హోమ్ పేజీలో ప్రదర్శించడానికి సాధనాలను ఎంచుకోండి.
|
||||
Unpin from Home హోమ్ నుండి అన్పిన్ చేయి
|
||||
Unpin tool సాధనాన్ని అన్పిన్ చేయి
|
||||
Transform image చిత్రాన్ని రూపాంతరం చేయండి
|
||||
Select Tool ఎంపిక సాధనం
|
||||
Hand Tool హ్యాండ్ సాధనం
|
||||
Marquee Zoom ఎంచుకున్న ప్రాంతానికి జూమ్
|
||||
Paste అతికించు
|
||||
Edit this PDF ఈ PDFను సవరించు
|
||||
Export this PDF ఈ PDFను ఎగుమти చేయి
|
||||
Find text or tools వచనం లేదా సాధనాలను కనుగొనండి
|
||||
Create & Edit సృష్టించు & సవరించు
|
||||
Review & Compare సమీక్షించు & పోల్చు
|
||||
Forms & Signatures ఫారమ్లు & సంతకాలు
|
||||
@@ -2471,6 +2495,13 @@ animation effects Off ఆఫ్
|
||||
field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name}
|
||||
two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది
|
||||
a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది
|
||||
Arrange image చిత్రాన్ని అమర్చు
|
||||
Bring forward ముందుకు తీసుకురా
|
||||
Bring to front అందరికంటే ముందుకు తీసుకురా
|
||||
Crop కత్తిరించు
|
||||
Remove crop కత్తిరింపును తీసివేయి
|
||||
Send backward వెనుకకు పంపు
|
||||
Send to back అందరికంటే వెనుకకు పంపు
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ఈరోజు
|
||||
@@ -2488,3 +2519,10 @@ animation effects Off ఆఫ్
|
||||
Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు
|
||||
Export value ఎగుమతి విలువ
|
||||
Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… శోధించండి…
|
||||
Image pasted చిత్రం అతికించబడింది
|
||||
No image on clipboard to paste అతికించడానికి క్లిప్బోర్డ్లో చిత్రం లేదు
|
||||
Pinned {tool} to Home {tool} హోమ్కు పిన్ చేయబడింది
|
||||
Unpinned {tool} from Home {tool} హోమ్ నుండి అన్పిన్ చేయబడింది
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -1217,3 +1217,12 @@ fn date_languages_are_the_interface_languages() {
|
||||
let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect();
|
||||
assert_eq!(dates, interface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_paste_and_home_notifications_are_translated() {
|
||||
for language in Lang::all().filter(|l| *l != Lang::EN) {
|
||||
for source in ["Search…", "Image pasted", "No image on clipboard to paste", "Pinned {tool} to Home", "Unpinned {tool} from Home"] {
|
||||
assert!(language.catalog().plain(source).is_some(), "{}: {source}", language.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
# names stay in Latin letters; the trailing ellipsis of dialog commands is kept. Thai has one
|
||||
# plural form.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu เมนู
|
||||
File ไฟล์
|
||||
Edit แก้ไข
|
||||
@@ -2711,6 +2715,13 @@ key Space Space
|
||||
Collapse all tools ยุบเครื่องมือทั้งหมด
|
||||
The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร
|
||||
theme Appearance ลักษณะที่ปรากฏ
|
||||
Arrange image จัดเรียงรูปภาพ
|
||||
Bring forward นำไปข้างหน้า
|
||||
Bring to front นำไปไว้ข้างหน้าสุด
|
||||
Crop ครอบตัด
|
||||
Remove crop เอาการครอบตัดออก
|
||||
Send backward ส่งไปข้างหลัง
|
||||
Send to back ส่งไปไว้ข้างหลังสุด
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today วันนี้
|
||||
@@ -2736,3 +2747,32 @@ theme Appearance ลักษณะที่ปรากฏ
|
||||
Unchecked reads Unchecked reads
|
||||
{field} reads fields named as it runs {field} reads fields named as it runs
|
||||
Some names are built as it runs Some names are built as it runs
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… ค้นหา…
|
||||
Image pasted วางรูปภาพแล้ว
|
||||
No image on clipboard to paste ไม่มีรูปภาพในคลิปบอร์ดให้วาง
|
||||
Pinned {tool} to Home ปักหมุด {tool} ไว้ที่หน้าแรกแล้ว
|
||||
Unpinned {tool} from Home เลิกปักหมุด {tool} จากหน้าแรกแล้ว
|
||||
|
||||
# Home tools (AI-assisted; not separately native-reviewed)
|
||||
Clear search ล้างการค้นหา
|
||||
Pin ปักหมุด
|
||||
Pin a tool… ปักหมุดเครื่องมือ…
|
||||
Pin to Home ปักหมุดไว้ที่หน้าแรก
|
||||
Pin tools to Home ปักหมุดเครื่องมือไว้ที่หน้าแรก
|
||||
Pin your favorite tools here for quick access. ปักหมุดเครื่องมือโปรดที่นี่เพื่อเข้าถึงอย่างรวดเร็ว
|
||||
Pinned ปักหมุดแล้ว
|
||||
Pinned Tools เครื่องมือที่ปักหมุด
|
||||
Search tools… ค้นหาเครื่องมือ…
|
||||
Select tools to display on your Home page for quick access. เลือกเครื่องมือที่จะแสดงบนหน้าแรกเพื่อเข้าถึงอย่างรวดเร็ว
|
||||
Unpin from Home เลิกปักหมุดจากหน้าแรก
|
||||
Unpin tool เลิกปักหมุดเครื่องมือ
|
||||
Transform image แปลงรูปภาพ
|
||||
Select Tool เครื่องมือเลือก
|
||||
Hand Tool เครื่องมือมือ
|
||||
Marquee Zoom ซูมพื้นที่ที่เลือก
|
||||
Edit this PDF แก้ไข PDF นี้
|
||||
Export this PDF ส่งออก PDF นี้
|
||||
Find text or tools ค้นหาข้อความหรือเครื่องมือ
|
||||
Crop image ครอบตัดรูปภาพ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 885 and column 12.
|
@@ -4,6 +4,10 @@
|
||||
#
|
||||
# Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Menü
|
||||
File Dosya
|
||||
Edit Düzen
|
||||
@@ -2511,6 +2515,26 @@ animation effects Off Kapalı
|
||||
Make properties default Özellikleri varsayılan yap
|
||||
Pages selected Seçilen sayfalar
|
||||
Page properties Sayfa özellikleri
|
||||
Clear search Aramayı temizle
|
||||
Pin Sabitle
|
||||
Pin a tool… Bir aracı sabitle…
|
||||
Pin to Home Giriş'e sabitle
|
||||
Pin tools to Home Araçları Giriş'e sabitle
|
||||
Pin your favorite tools here for quick access. Hızlı erişim için favori araçlarınızı buraya sabitleyin.
|
||||
Pinned Sabitlendi
|
||||
Pinned Tools Sabitlenen Araçlar
|
||||
Search tools… Araçları ara…
|
||||
Select tools to display on your Home page for quick access. Hızlı erişim için Giriş sayfanızda görüntülenecek araçları seçin.
|
||||
Unpin from Home Giriş'ten sabitlemeyi kaldır
|
||||
Unpin tool Aracın sabitlemesini kaldır
|
||||
Transform image Görüntüyü dönüştür
|
||||
Select Tool Seçim Aracı
|
||||
Hand Tool El Aracı
|
||||
Marquee Zoom Seçim yakınlaştırma
|
||||
Paste Yapıştır
|
||||
Edit this PDF Bu PDF'i düzenle
|
||||
Export this PDF Bu PDF'i dışa aktar
|
||||
Find text or tools Metin veya araç bul
|
||||
Create & Edit Oluştur ve Düzenle
|
||||
Review & Compare İncele ve Karşılaştır
|
||||
Forms & Signatures Formlar ve İmzalar
|
||||
@@ -2558,6 +2582,13 @@ animation effects Off Kapalı
|
||||
field names can't contain a period: {name} alan adları nokta içeremez: {name}
|
||||
two cells would both be named {name} iki hücrenin de adı {name} olurdu
|
||||
a field named {name} already exists {name} adlı bir alan zaten var
|
||||
Arrange image Resmi düzenle
|
||||
Bring forward Öne getir
|
||||
Bring to front En öne getir
|
||||
Crop Kırp
|
||||
Remove crop Kırpmayı kaldır
|
||||
Send backward Arkaya gönder
|
||||
Send to back En arkaya gönder
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Bugün
|
||||
@@ -2575,3 +2606,10 @@ animation effects Off Kapalı
|
||||
Options for this reply Bu yanıtın seçenekleri
|
||||
Export value Dışa aktarma değeri
|
||||
Show Page Edge Shadows Sayfa kenarı gölgelerini göster
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Ara…
|
||||
Image pasted Görüntü yapıştırıldı
|
||||
No image on clipboard to paste Panoda yapıştırılacak görüntü yok
|
||||
Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi
|
||||
Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 884 and column 9.
|
@@ -18,6 +18,10 @@
|
||||
# Integer plurals (CLDR uk): one (1, 21) | few (2–4, 22–24) | many (0, 5–19); 11–14 take many.
|
||||
# Commands use infinitives. Fixed-count templates use count-neutral wording.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Меню
|
||||
File Файл
|
||||
Edit Редагування
|
||||
@@ -2537,6 +2541,26 @@ animation effects Off Вимкнено
|
||||
Make properties default Зробити властивості типовими
|
||||
Pages selected Вибрані сторінки
|
||||
Page properties Властивості сторінки
|
||||
Clear search Очистити пошук
|
||||
Pin Закріпити
|
||||
Pin a tool… Закріпити інструмент…
|
||||
Pin to Home Закріпити на Головній
|
||||
Pin tools to Home Закріпити інструменти на Головній
|
||||
Pin your favorite tools here for quick access. Закріпіть улюблені інструменти тут для швидкого доступу.
|
||||
Pinned Закріплено
|
||||
Pinned Tools Закріплені інструменти
|
||||
Search tools… Шукати інструменти…
|
||||
Select tools to display on your Home page for quick access. Виберіть інструменти для відображення на Головній для швидкого доступу.
|
||||
Unpin from Home Відкріпити від Головної
|
||||
Unpin tool Відкріпити інструмент
|
||||
Transform image Трансформувати зображення
|
||||
Select Tool Інструмент виділення
|
||||
Hand Tool Інструмент «Рука»
|
||||
Marquee Zoom Масштабувати виділену область
|
||||
Paste Вставити
|
||||
Edit this PDF Редактировать цей PDF
|
||||
Export this PDF Експортувати цей PDF
|
||||
Find text or tools Знайти текст або інструменти
|
||||
Create & Edit Створення й редагування
|
||||
Review & Compare Рецензування й порівняння
|
||||
Forms & Signatures Форми й підписи
|
||||
@@ -2584,6 +2608,13 @@ animation effects Off Вимкнено
|
||||
field names can't contain a period: {name} імена полів не можуть містити крапку: {name}
|
||||
two cells would both be named {name} дві клітинки мали б однакове ім'я {name}
|
||||
a field named {name} already exists поле з іменем {name} уже існує
|
||||
Arrange image Упорядкувати зображення
|
||||
Bring forward Перемістити вперед
|
||||
Bring to front На передній план
|
||||
Crop Обрізати
|
||||
Remove crop Прибрати обрізання
|
||||
Send backward Перемістити назад
|
||||
Send to back На задній план
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Сьогодні
|
||||
@@ -2601,3 +2632,10 @@ animation effects Off Вимкнено
|
||||
Options for this reply Параметри цієї відповіді
|
||||
Export value Значення експорту
|
||||
Show Page Edge Shadows Показувати тіні на краях сторінок
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Пошук…
|
||||
Image pasted Зображення вставлено
|
||||
No image on clipboard to paste У буфері обміну немає зображення для вставлення
|
||||
Pinned {tool} to Home {tool} закріплено на головній
|
||||
Unpinned {tool} from Home {tool} відкріплено від головної
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -7,6 +7,11 @@
|
||||
# Plural rule: `plural_none` — every @plural row lists exactly one form.
|
||||
# Context-sensitive wording and intentional unchanged strings are regression-tested in i18n/mod.rs.
|
||||
#
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu Trình đơn
|
||||
Edit Chỉnh sửa
|
||||
Pages Trang
|
||||
@@ -2702,6 +2707,12 @@ key Space Space
|
||||
Redact Image Xóa nội dung nhạy cảm của hình ảnh
|
||||
Create Link… Tạo liên kết…
|
||||
Recognize Text… Nhận dạng văn bản…
|
||||
Bring forward Chuyển lên trước
|
||||
Bring to front Đưa lên trước cùng
|
||||
Crop image Cắt hình ảnh
|
||||
Remove crop Xóa cắt
|
||||
Send backward Chuyển về sau
|
||||
Send to back Đưa về sau cùng
|
||||
Create & Edit Tạo và chỉnh sửa
|
||||
Review & Compare Xem xét và so sánh
|
||||
Forms & Signatures Biểu mẫu và chữ ký
|
||||
@@ -2789,6 +2800,25 @@ key Space Space
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
|
||||
Clear search Xóa tìm kiếm
|
||||
Pin Ghim
|
||||
Pin a tool… Ghim công cụ…
|
||||
Pin to Home Ghim vào Trang chủ
|
||||
Pin tools to Home Ghim công cụ vào Trang chủ
|
||||
Pin your favorite tools here for quick access. Ghim các công cụ yêu thích của bạn vào đây để truy cập nhanh.
|
||||
Pinned Đã ghim
|
||||
Pinned Tools Công cụ đã ghim
|
||||
Search tools… Tìm kiếm công cụ…
|
||||
Select tools to display on your Home page for quick access. Chọn các công cụ hiển thị trên Trang chủ để truy cập nhanh.
|
||||
Unpin from Home Bỏ ghim khỏi Trang chủ
|
||||
Unpin tool Bỏ ghim công cụ
|
||||
Transform image Chuyển đổi hình ảnh
|
||||
Select Tool Công cụ chọn
|
||||
Hand Tool Công cụ bàn tay
|
||||
Marquee Zoom Thu phóng theo vùng chọn
|
||||
Edit this PDF Chỉnh sửa PDF này
|
||||
Export this PDF Xuất PDF này
|
||||
Find text or tools Tìm văn bản hoặc công cụ
|
||||
Attach a file… Đính kèm tệp…
|
||||
Delete attachment Xóa tệp đính kèm
|
||||
Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web
|
||||
@@ -2826,6 +2856,8 @@ key Space Space
|
||||
a table has 1 to {max} columns một bảng có từ 1 đến {max} cột
|
||||
the first row number can be at most {max} số hàng đầu tiên tối đa là {max}
|
||||
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0
|
||||
Arrange image Sắp xếp hình ảnh
|
||||
Crop Xén
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Hôm nay
|
||||
@@ -2846,3 +2878,10 @@ key Space Space
|
||||
Options for this reply Tùy chọn cho câu trả lời này
|
||||
Export value Giá trị xuất
|
||||
Show Page Edge Shadows Hiển thị bóng viền trang
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… Tìm kiếm…
|
||||
Image pasted Đã dán hình ảnh
|
||||
No image on clipboard to paste Không có hình ảnh trong bảng nhớ tạm để dán
|
||||
Pinned {tool} to Home Đã ghim {tool} vào Trang chủ
|
||||
Unpinned {tool} from Home Đã bỏ ghim {tool} khỏi Trang chủ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 495 and column 12.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
# About, contributor/model credits, and multiline interface guidance.
|
||||
About 关于
|
||||
Contributors 贡献者
|
||||
@@ -2479,6 +2483,26 @@ animation effects Off 关
|
||||
Make properties default 将属性设为默认
|
||||
Pages selected 选定的页面
|
||||
Page properties 页面属性
|
||||
Clear search 清除搜索
|
||||
Pin 固定
|
||||
Pin a tool… 固定工具…
|
||||
Pin to Home 固定到主页
|
||||
Pin tools to Home 将工具固定到主页
|
||||
Pin your favorite tools here for quick access. 将常用的工具固定在此处以便快速访问。
|
||||
Pinned 已固定
|
||||
Pinned Tools 已固定的工具
|
||||
Search tools… 搜索工具…
|
||||
Select tools to display on your Home page for quick access. 选择要在主页上显示的工具以便快速访问。
|
||||
Unpin from Home 从主页取消固定
|
||||
Unpin tool 取消固定工具
|
||||
Transform image 变换图像
|
||||
Select Tool 选择工具
|
||||
Hand Tool 手形工具
|
||||
Marquee Zoom 选框缩放
|
||||
Paste 粘贴
|
||||
Edit this PDF 编辑此 PDF
|
||||
Export this PDF 导出此 PDF
|
||||
Find text or tools 查找文本或工具
|
||||
Create & Edit 创建和编辑
|
||||
Review & Compare 审阅和比较
|
||||
Forms & Signatures 表单和签名
|
||||
@@ -2526,6 +2550,13 @@ animation effects Off 关
|
||||
field names can't contain a period: {name} 字段名称不能包含句点:{name}
|
||||
two cells would both be named {name} 两个单元格的名称都会是 {name}
|
||||
a field named {name} already exists 已存在名为 {name} 的字段
|
||||
Arrange image 排列图像
|
||||
Bring forward 上移一层
|
||||
Bring to front 置于顶层
|
||||
Crop 裁剪
|
||||
Remove crop 移除裁剪
|
||||
Send backward 下移一层
|
||||
Send to back 置于底层
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@@ -2543,3 +2574,10 @@ animation effects Off 关
|
||||
Options for this reply 此回复的选项
|
||||
Export value 导出值
|
||||
Show Page Edge Shadows 显示页面边缘阴影
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… 搜索…
|
||||
Image pasted 已粘贴图像
|
||||
No image on clipboard to paste 剪贴板中没有可粘贴的图像
|
||||
Pinned {tool} to Home 已将 {tool} 固定到主页
|
||||
Unpinned {tool} from Home 已从主页取消固定 {tool}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -8,6 +8,10 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept.
|
||||
|
||||
# Untranslated: English text until a translator fills these in.
|
||||
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
|
||||
Recent files to keep Recent files to keep
|
||||
|
||||
Menu 選單
|
||||
File 檔案
|
||||
Edit 編輯
|
||||
@@ -2465,6 +2469,26 @@ animation effects Off 關
|
||||
Make properties default 將內容設為預設
|
||||
Pages selected 選取的頁面
|
||||
Page properties 頁面屬性
|
||||
Clear search 清除搜尋
|
||||
Pin 釘選
|
||||
Pin a tool… 釘選工具…
|
||||
Pin to Home 釘選到首頁
|
||||
Pin tools to Home 將工具釘選到首頁
|
||||
Pin your favorite tools here for quick access. 將常用的工具釘選在此處以便快速存取。
|
||||
Pinned 已釘選
|
||||
Pinned Tools 已釘選的工具
|
||||
Search tools… 搜尋工具…
|
||||
Select tools to display on your Home page for quick access. 選取要在首頁上顯示的工具以便快速存取。
|
||||
Unpin from Home 從首頁取消釘選
|
||||
Unpin tool 取消釘選工具
|
||||
Transform image 變換影像
|
||||
Select Tool 選取工具
|
||||
Hand Tool 手形工具
|
||||
Marquee Zoom 選取範圍縮放
|
||||
Paste 貼上
|
||||
Edit this PDF 編輯此 PDF
|
||||
Export this PDF 匯出此 PDF
|
||||
Find text or tools 尋找文字或工具
|
||||
Create & Edit 建立與編輯
|
||||
Review & Compare 審閱與比較
|
||||
Forms & Signatures 表單與簽名
|
||||
@@ -2512,6 +2536,13 @@ animation effects Off 關
|
||||
field names can't contain a period: {name} 欄位名稱不能包含句點:{name}
|
||||
two cells would both be named {name} 兩個儲存格的名稱都會是 {name}
|
||||
a field named {name} already exists 已有名為 {name} 的欄位
|
||||
Arrange image 排列影像
|
||||
Bring forward 上移一層
|
||||
Bring to front 移到最上層
|
||||
Crop 裁切
|
||||
Remove crop 移除裁切
|
||||
Send backward 下移一層
|
||||
Send to back 移到最下層
|
||||
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today 今天
|
||||
@@ -2529,3 +2560,10 @@ animation effects Off 關
|
||||
Options for this reply 此回覆的選項
|
||||
Export value 匯出值
|
||||
Show Page Edge Shadows 顯示頁面邊緣陰影
|
||||
|
||||
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
|
||||
Search… 搜尋…
|
||||
Image pasted 已貼上影像
|
||||
No image on clipboard to paste 剪貼簿中沒有可貼上的影像
|
||||
Pinned {tool} to Home 已將 {tool} 釘選至首頁
|
||||
Unpinned {tool} from Home 已從首頁取消釘選 {tool}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -0,0 +1,578 @@
|
||||
//! Image manipulation engine for non-destructive crop, anchor-based resize, and movement.
|
||||
//!
|
||||
//! Contributor-original clean-room design with zero-panic safety and lockstep pixel-scale invariance.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Stroke, Vec2, pos2};
|
||||
|
||||
use crate::edit_text_ui::ImageHandle;
|
||||
|
||||
// ============================================================================
|
||||
// 1. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP
|
||||
// ============================================================================
|
||||
|
||||
/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects.
|
||||
pub const MIN_RECT_SIZE: f32 = 10.0;
|
||||
|
||||
/// **Anchor-Based Resize System**
|
||||
///
|
||||
/// Recalculates the screen bounding box by locking the diagonally opposite point/edge as
|
||||
/// a fixed anchor.
|
||||
///
|
||||
/// Math breakdown:
|
||||
/// - Corner handles: The diagonally opposite corner is fixed in both X and Y.
|
||||
/// Width and height are calculated relative to that anchor, clamped to >= `MIN_RECT_SIZE`,
|
||||
/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate.
|
||||
/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is
|
||||
/// completely unaffected.
|
||||
pub fn apply_resize(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
|
||||
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return image_rect;
|
||||
}
|
||||
let mut min = image_rect.min;
|
||||
let mut max = image_rect.max;
|
||||
|
||||
match handle {
|
||||
// Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y)
|
||||
ImageHandle::TopLeft => {
|
||||
let anchor = max;
|
||||
let target = min + delta;
|
||||
let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE);
|
||||
let new_h = (anchor.y - target.y).max(MIN_RECT_SIZE);
|
||||
min.x = anchor.x - new_w;
|
||||
min.y = anchor.y - new_h;
|
||||
}
|
||||
|
||||
// Dragging TopCenter: Fixed Anchor = Bottom edge (max.y)
|
||||
ImageHandle::TopCenter => {
|
||||
let anchor_y = max.y;
|
||||
let target_y = min.y + delta.y;
|
||||
let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
|
||||
min.y = anchor_y - new_h;
|
||||
}
|
||||
|
||||
// Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y)
|
||||
ImageHandle::TopRight => {
|
||||
let anchor_x = min.x;
|
||||
let anchor_y = max.y;
|
||||
let target_x = max.x + delta.x;
|
||||
let target_y = min.y + delta.y;
|
||||
let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
|
||||
let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
|
||||
max.x = anchor_x + new_w;
|
||||
min.y = anchor_y - new_h;
|
||||
}
|
||||
|
||||
// Dragging RightCenter: Fixed Anchor = Left edge (min.x)
|
||||
ImageHandle::RightCenter => {
|
||||
let anchor_x = min.x;
|
||||
let target_x = max.x + delta.x;
|
||||
let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
|
||||
max.x = anchor_x + new_w;
|
||||
}
|
||||
|
||||
// Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y)
|
||||
ImageHandle::BottomRight => {
|
||||
let anchor = min;
|
||||
let target = max + delta;
|
||||
let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE);
|
||||
let new_h = (target.y - anchor.y).max(MIN_RECT_SIZE);
|
||||
max.x = anchor.x + new_w;
|
||||
max.y = anchor.y + new_h;
|
||||
}
|
||||
|
||||
// Dragging BottomCenter: Fixed Anchor = Top edge (min.y)
|
||||
ImageHandle::BottomCenter => {
|
||||
let anchor_y = min.y;
|
||||
let target_y = max.y + delta.y;
|
||||
let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
|
||||
max.y = anchor_y + new_h;
|
||||
}
|
||||
|
||||
// Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y)
|
||||
ImageHandle::BottomLeft => {
|
||||
let anchor_x = max.x;
|
||||
let anchor_y = min.y;
|
||||
let target_x = min.x + delta.x;
|
||||
let target_y = max.y + delta.y;
|
||||
let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
|
||||
let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
|
||||
min.x = anchor_x - new_w;
|
||||
max.y = anchor_y + new_h;
|
||||
}
|
||||
|
||||
// Dragging LeftCenter: Fixed Anchor = Right edge (max.x)
|
||||
ImageHandle::LeftCenter => {
|
||||
let anchor_x = max.x;
|
||||
let target_x = min.x + delta.x;
|
||||
let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
|
||||
min.x = anchor_x - new_w;
|
||||
}
|
||||
}
|
||||
|
||||
Rect::from_min_max(min, max)
|
||||
}
|
||||
|
||||
/// **Anchor-Based Proportional Resize System**
|
||||
///
|
||||
/// Recalculates the screen bounding box by locking the diagonally opposite point as
|
||||
/// a fixed anchor while preserving the aspect ratio.
|
||||
///
|
||||
/// For corner handles, uses the axis with the larger relative change to scale both
|
||||
/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`.
|
||||
/// For mid-edge handles, delegates to `apply_resize`.
|
||||
pub fn apply_resize_proportional(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
|
||||
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return image_rect;
|
||||
}
|
||||
let w0 = image_rect.width().max(1.0);
|
||||
let h0 = image_rect.height().max(1.0);
|
||||
|
||||
match handle {
|
||||
ImageHandle::TopLeft => {
|
||||
let horizontal = -delta.x / w0;
|
||||
let vertical = -delta.y / h0;
|
||||
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
|
||||
let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
|
||||
let (w, h) = (w0 * k, h0 * k);
|
||||
Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max)
|
||||
}
|
||||
ImageHandle::TopRight => {
|
||||
let horizontal = delta.x / w0;
|
||||
let vertical = -delta.y / h0;
|
||||
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
|
||||
let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
|
||||
let (w, h) = (w0 * k, h0 * k);
|
||||
Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y))
|
||||
}
|
||||
ImageHandle::BottomLeft => {
|
||||
let horizontal = -delta.x / w0;
|
||||
let vertical = delta.y / h0;
|
||||
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
|
||||
let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
|
||||
let (w, h) = (w0 * k, h0 * k);
|
||||
Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h))
|
||||
}
|
||||
ImageHandle::BottomRight => {
|
||||
let horizontal = delta.x / w0;
|
||||
let vertical = delta.y / h0;
|
||||
let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
|
||||
let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
|
||||
let (w, h) = (w0 * k, h0 * k);
|
||||
Rect::from_min_max(image_rect.min, Pos2::new(image_rect.min.x + w, image_rect.min.y + h))
|
||||
}
|
||||
mid => apply_resize(image_rect, mid, delta),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim a screen rectangle and its UVs together, keeping the opposite edge fixed.
|
||||
/// The pixel scale stays unchanged, and each edge stops at the texture boundary.
|
||||
pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: ImageHandle, delta: Vec2) -> (Rect, Rect) {
|
||||
if !delta.is_finite() || !image_rect.is_finite() || !uv_rect.is_finite() || !image_rect.is_positive() || !uv_rect.is_positive() {
|
||||
return (image_rect, uv_rect);
|
||||
}
|
||||
let scale = image_rect.size() / uv_rect.size();
|
||||
if !scale.is_finite() || scale.x <= 0.0 || scale.y <= 0.0 {
|
||||
return (image_rect, uv_rect);
|
||||
}
|
||||
let minimum = (egui::vec2(MIN_RECT_SIZE, MIN_RECT_SIZE) / scale).min(uv_rect.size() * 0.95);
|
||||
let mut uv = uv_rect;
|
||||
if matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) {
|
||||
uv.min.x = (uv.min.x + delta.x / scale.x).clamp(0.0, (uv.max.x - minimum.x).max(0.0));
|
||||
} else if matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) {
|
||||
uv.max.x = (uv.max.x + delta.x / scale.x).clamp((uv.min.x + minimum.x).min(1.0), 1.0);
|
||||
}
|
||||
if matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) {
|
||||
uv.min.y = (uv.min.y + delta.y / scale.y).clamp(0.0, (uv.max.y - minimum.y).max(0.0));
|
||||
} else if matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) {
|
||||
uv.max.y = (uv.max.y + delta.y / scale.y).clamp((uv.min.y + minimum.y).min(1.0), 1.0);
|
||||
}
|
||||
// Reconstruct from the fixed opposite edge, rather than adding a nearly full-width
|
||||
// shift to the moved edge. Calculate in f64 so each screen coordinate rounds only
|
||||
// once to f32; small crops far from the origin otherwise lose multiple ulps.
|
||||
let span = |min: f32, max: f32| f64::from(max) - f64::from(min);
|
||||
let width = span(image_rect.min.x, image_rect.max.x) * span(uv.min.x, uv.max.x) / span(uv_rect.min.x, uv_rect.max.x);
|
||||
let height = span(image_rect.min.y, image_rect.max.y) * span(uv.min.y, uv.max.y) / span(uv_rect.min.y, uv_rect.max.y);
|
||||
let mut screen = image_rect;
|
||||
if uv.min.x != uv_rect.min.x {
|
||||
screen.min.x = (f64::from(image_rect.max.x) - width) as f32;
|
||||
} else if uv.max.x != uv_rect.max.x {
|
||||
screen.max.x = (f64::from(image_rect.min.x) + width) as f32;
|
||||
}
|
||||
if uv.min.y != uv_rect.min.y {
|
||||
screen.min.y = (f64::from(image_rect.max.y) - height) as f32;
|
||||
} else if uv.max.y != uv_rect.max.y {
|
||||
screen.max.y = (f64::from(image_rect.min.y) + height) as f32;
|
||||
}
|
||||
if !screen.is_finite() || !screen.is_positive() {
|
||||
return (image_rect, uv_rect);
|
||||
}
|
||||
(screen, uv)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2. VISUAL RENDERING HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/// Vibrant amber/orange color for Crop Mode.
|
||||
pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34);
|
||||
|
||||
/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle.
|
||||
pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) {
|
||||
let w = rect.width();
|
||||
let h = rect.height();
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let x1 = rect.min.x + w / 3.0;
|
||||
let x2 = rect.min.x + (2.0 * w) / 3.0;
|
||||
painter.line_segment([pos2(x1, rect.min.y), pos2(x1, rect.max.y)], stroke);
|
||||
painter.line_segment([pos2(x2, rect.min.y), pos2(x2, rect.max.y)], stroke);
|
||||
|
||||
let y1 = rect.min.y + h / 3.0;
|
||||
let y2 = rect.min.y + (2.0 * h) / 3.0;
|
||||
painter.line_segment([pos2(rect.min.x, y1), pos2(rect.max.x, y1)], stroke);
|
||||
painter.line_segment([pos2(rect.min.x, y2), pos2(rect.max.x, y2)], stroke);
|
||||
}
|
||||
|
||||
/// Renders a circular handle with shadow, fill, and accent stroke using the image tool accent.
|
||||
pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_hovered: bool, crop_mode: bool, accent_color: Color32) {
|
||||
let base_radius = 6.5;
|
||||
let circle_radius = if is_active {
|
||||
base_radius + 2.0
|
||||
} else if is_hovered {
|
||||
base_radius + 1.2
|
||||
} else {
|
||||
base_radius
|
||||
};
|
||||
|
||||
let fill_color = if crop_mode {
|
||||
if is_active || is_hovered { Color32::from_rgb(255, 230, 150) } else { Color32::from_rgb(255, 255, 255) }
|
||||
} else if is_active || is_hovered {
|
||||
Color32::from_rgb(210, 235, 255)
|
||||
} else {
|
||||
Color32::from_rgb(255, 255, 255)
|
||||
};
|
||||
|
||||
// Draw handle shadow/glow
|
||||
painter.circle_filled(center, circle_radius + 1.5, Color32::from_rgba_unmultiplied(0, 0, 0, 80));
|
||||
|
||||
// Draw handle circle
|
||||
painter.circle_filled(center, circle_radius, fill_color);
|
||||
painter.circle_stroke(center, circle_radius, Stroke::new(1.8, accent_color));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 3. UNIT TESTS: MATHEMATICAL VERIFICATION
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::vec2;
|
||||
|
||||
// Verify scale in screen space: dividing an endpoint's rounding error by a tiny
|
||||
// retained UV span would magnify it and make a fixed scale tolerance invalid.
|
||||
fn crop_scale_preserved(before: Rect, uv_before: Rect, after: Rect, uv_after: Rect) -> bool {
|
||||
let axis = |lo: f32, hi: f32, u0: f32, u1: f32, new_lo: f32, new_hi: f32, v0: f32, v1: f32| {
|
||||
let span = f64::from(hi) - f64::from(lo);
|
||||
let expected = span * (f64::from(v1) - f64::from(v0)) / (f64::from(u1) - f64::from(u0));
|
||||
let actual = f64::from(new_hi) - f64::from(new_lo);
|
||||
// One moved endpoint rounds once to f32. EPSILON * coordinate magnitude
|
||||
// bounds one ulp (conservatively twice the nearest-rounding error).
|
||||
// The f64 term also covers the two arithmetic operations above.
|
||||
let magnitude = f64::from(new_lo).abs().max(f64::from(new_hi).abs()).max(expected.abs());
|
||||
let bound = f64::from(f32::EPSILON) * magnitude + 4.0 * f64::EPSILON * span.abs();
|
||||
(actual - expected).abs() <= bound
|
||||
};
|
||||
axis(before.min.x, before.max.x, uv_before.min.x, uv_before.max.x, after.min.x, after.max.x, uv_after.min.x, uv_after.max.x)
|
||||
&& axis(before.min.y, before.max.y, uv_before.min.y, uv_before.max.y, after.min.y, after.max.y, uv_after.min.y, uv_after.max.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_scale_at_large_screen_offset() {
|
||||
let screen = Rect::from_min_size(pos2(813.7157, 0.0), vec2(495.0725, 50.0));
|
||||
let uv = Rect::from_min_size(pos2(0.114936605, 0.0), vec2(0.32074833, 0.3));
|
||||
let (cropped, kept) = apply_crop(screen, uv, ImageHandle::BottomLeft, vec2(1255.676, 0.0));
|
||||
let width = (f64::from(screen.max.x) - f64::from(screen.min.x)) * (f64::from(kept.max.x) - f64::from(kept.min.x))
|
||||
/ (f64::from(uv.max.x) - f64::from(uv.min.x));
|
||||
assert_eq!(cropped.min.x, (f64::from(screen.max.x) - width) as f32, "moved edge rounds once from the fixed anchor");
|
||||
assert_eq!(cropped.max.x, screen.max.x, "opposite horizontal edge stays fixed");
|
||||
assert_eq!(cropped.min.y, screen.min.y, "opposite vertical edge stays fixed");
|
||||
assert!(crop_scale_preserved(screen, uv, cropped, kept));
|
||||
let stretched = Rect::from_min_max(cropped.min - vec2(0.1, 0.0), cropped.max);
|
||||
assert!(!crop_scale_preserved(screen, uv, stretched, kept), "a visible stretch must exceed the rounding bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_bottom_right_anchor() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
|
||||
let res = apply_resize(r, ImageHandle::BottomRight, vec2(50.0, 25.0));
|
||||
assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed");
|
||||
assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_top_left_anchor() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
|
||||
let res = apply_resize(r, ImageHandle::TopLeft, vec2(20.0, 15.0));
|
||||
assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed");
|
||||
assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_clamping_prevents_inversion() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0));
|
||||
let res = apply_resize(r, ImageHandle::TopLeft, vec2(500.0, 500.0));
|
||||
assert_eq!(res.max, pos2(200.0, 200.0));
|
||||
assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE));
|
||||
assert!(res.width() >= MIN_RECT_SIZE);
|
||||
assert!(res.height() >= MIN_RECT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_top_right_anchor() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
|
||||
let res = apply_resize(r, ImageHandle::TopRight, vec2(10.0, -10.0));
|
||||
assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed");
|
||||
assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed");
|
||||
assert_eq!(res.max.x, 310.0);
|
||||
assert_eq!(res.min.y, 90.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_bottom_left_anchor() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
|
||||
let res = apply_resize(r, ImageHandle::BottomLeft, vec2(-15.0, 20.0));
|
||||
assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed");
|
||||
assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed");
|
||||
assert_eq!(res.min.x, 85.0);
|
||||
assert_eq!(res.max.y, 220.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_mid_edges() {
|
||||
let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
|
||||
|
||||
let tm = apply_resize(r, ImageHandle::TopCenter, vec2(50.0, -30.0));
|
||||
assert_eq!(tm.min.x, 100.0, "X min unaffected");
|
||||
assert_eq!(tm.max.x, 300.0, "X max unaffected");
|
||||
assert_eq!(tm.max.y, 200.0, "Y max anchor locked");
|
||||
assert_eq!(tm.min.y, 70.0);
|
||||
|
||||
let rm = apply_resize(r, ImageHandle::RightCenter, vec2(40.0, 50.0));
|
||||
assert_eq!(rm.min.x, 100.0, "X min anchor locked");
|
||||
assert_eq!(rm.max.x, 340.0);
|
||||
assert_eq!(rm.min.y, 100.0, "Y unaffected");
|
||||
assert_eq!(rm.max.y, 200.0, "Y unaffected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crop_lockstep_ratio_preservation() {
|
||||
let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
|
||||
let initial_px_per_uv_x = screen.width() / uv.width();
|
||||
let initial_px_per_uv_y = screen.height() / uv.height();
|
||||
|
||||
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(-80.0, 0.0));
|
||||
|
||||
assert_eq!(new_screen.min.x, 100.0, "Left edge anchored");
|
||||
assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px");
|
||||
assert_eq!(new_screen.width(), 320.0);
|
||||
|
||||
assert_eq!(new_uv.min.x, 0.0);
|
||||
assert!((new_uv.max.x - 0.8).abs() < 1e-4);
|
||||
|
||||
let new_px_per_uv_x = new_screen.width() / new_uv.width();
|
||||
assert!((new_px_per_uv_x - initial_px_per_uv_x).abs() < 1e-4, "Pixel scale must remain invariant in crop mode");
|
||||
|
||||
let new_px_per_uv_y = new_screen.height() / new_uv.height();
|
||||
assert!((new_px_per_uv_y - initial_px_per_uv_y).abs() < 1e-4, "Pixel scale Y must remain invariant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crop_left_edge_anchoring() {
|
||||
let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
|
||||
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(50.0, 0.0));
|
||||
|
||||
assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored");
|
||||
assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px");
|
||||
assert_eq!(new_screen.width(), 350.0);
|
||||
|
||||
assert!((new_uv.min.x - 0.125).abs() < 1e-4);
|
||||
assert_eq!(new_uv.max.x, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crop_uncrop_clamped_at_zero_and_one() {
|
||||
let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
|
||||
let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(-100.0, 0.0));
|
||||
assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0");
|
||||
assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position");
|
||||
|
||||
let (new_screen2, new_uv2) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(100.0, 0.0));
|
||||
assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0");
|
||||
assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() {
|
||||
let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100
|
||||
// TopRight corner moved horizontally by -25px (from x=200 to x=175)
|
||||
let resized = apply_resize_proportional(rect, ImageHandle::TopRight, vec2(-25.0, 0.0));
|
||||
assert_eq!(resized.min.x, 100.0, "Left anchor preserved");
|
||||
assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved");
|
||||
assert_eq!(resized.width(), 75.0, "Width scaled to 75%");
|
||||
assert_eq!(resized.height(), 75.0, "Height scaled to 75% proportionally");
|
||||
assert_eq!(resized.max.x, 175.0);
|
||||
assert_eq!(resized.min.y, 125.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_resize_proportional_bottom_right_anchor_locked() {
|
||||
let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200
|
||||
// BottomRight moved down/right by (20px, 40px)
|
||||
let resized = apply_resize_proportional(rect, ImageHandle::BottomRight, vec2(20.0, 40.0));
|
||||
assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked");
|
||||
assert_eq!(resized.width(), 120.0);
|
||||
assert_eq!(resized.height(), 240.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod proptests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn arb_handle() -> impl Strategy<Value = ImageHandle> {
|
||||
prop_oneof![
|
||||
Just(ImageHandle::TopLeft),
|
||||
Just(ImageHandle::TopCenter),
|
||||
Just(ImageHandle::TopRight),
|
||||
Just(ImageHandle::RightCenter),
|
||||
Just(ImageHandle::BottomRight),
|
||||
Just(ImageHandle::BottomCenter),
|
||||
Just(ImageHandle::BottomLeft),
|
||||
Just(ImageHandle::LeftCenter),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn prop_apply_resize_never_inverts_and_respects_min_size(
|
||||
min_x in -5000.0f32..5000.0,
|
||||
min_y in -5000.0f32..5000.0,
|
||||
w in 10.0f32..2000.0,
|
||||
h in 10.0f32..2000.0,
|
||||
handle in arb_handle(),
|
||||
dx in -10000.0f32..10000.0,
|
||||
dy in -10000.0f32..10000.0,
|
||||
) {
|
||||
let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
|
||||
let res = apply_resize(rect, handle, vec2(dx, dy));
|
||||
prop_assert!(res.is_finite());
|
||||
prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
|
||||
prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
|
||||
prop_assert!(res.min.x <= res.max.x);
|
||||
prop_assert!(res.min.y <= res.max.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_apply_resize_proportional_preserves_aspect_ratio(
|
||||
min_x in -2000.0f32..2000.0,
|
||||
min_y in -2000.0f32..2000.0,
|
||||
w in 20.0f32..1000.0,
|
||||
h in 20.0f32..1000.0,
|
||||
handle in arb_handle(),
|
||||
dx in -5000.0f32..5000.0,
|
||||
dy in -5000.0f32..5000.0,
|
||||
) {
|
||||
let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
|
||||
let res = apply_resize_proportional(rect, handle, vec2(dx, dy));
|
||||
prop_assert!(res.is_finite());
|
||||
prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
|
||||
prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
|
||||
|
||||
let is_corner = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight);
|
||||
if is_corner {
|
||||
let orig_ratio = w / h;
|
||||
let new_ratio = res.width() / res.height();
|
||||
prop_assert!((orig_ratio - new_ratio).abs() <= 1e-3 * orig_ratio.max(1.0), "orig: {orig_ratio}, new: {new_ratio}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_apply_crop_uv_clamped(
|
||||
min_x in 0.0f32..1000.0,
|
||||
min_y in 0.0f32..1000.0,
|
||||
w in 50.0f32..500.0,
|
||||
h in 50.0f32..500.0,
|
||||
uv_u0 in 0.0f32..0.4,
|
||||
uv_v0 in 0.0f32..0.4,
|
||||
uv_w in 0.3f32..0.5,
|
||||
uv_h in 0.3f32..0.5,
|
||||
handle in arb_handle(),
|
||||
dx in -2000.0f32..2000.0,
|
||||
dy in -2000.0f32..2000.0,
|
||||
) {
|
||||
let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
|
||||
let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h));
|
||||
let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy));
|
||||
|
||||
prop_assert!(new_screen.is_finite());
|
||||
prop_assert!(new_uv.is_finite());
|
||||
prop_assert!(new_uv.min.x >= 0.0 - 1e-5);
|
||||
prop_assert!(new_uv.min.y >= 0.0 - 1e-5);
|
||||
prop_assert!(new_uv.max.x <= 1.0 + 1e-5);
|
||||
prop_assert!(new_uv.max.y <= 1.0 + 1e-5);
|
||||
prop_assert!(new_uv.width() > 0.0);
|
||||
prop_assert!(new_uv.height() > 0.0);
|
||||
|
||||
if !matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) {
|
||||
prop_assert_eq!(new_screen.min.x, screen.min.x, "stationary left edge moved");
|
||||
}
|
||||
if !matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) {
|
||||
prop_assert_eq!(new_screen.max.x, screen.max.x, "stationary right edge moved");
|
||||
}
|
||||
if !matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) {
|
||||
prop_assert_eq!(new_screen.min.y, screen.min.y, "stationary top edge moved");
|
||||
}
|
||||
if !matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) {
|
||||
prop_assert_eq!(new_screen.max.y, screen.max.y, "stationary bottom edge moved");
|
||||
}
|
||||
|
||||
prop_assert!(crop_scale_preserved(screen, uv, new_screen, new_uv),
|
||||
"screen/UV scale changed beyond endpoint rounding: {screen:?} {uv:?} => {new_screen:?} {new_uv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_quad_contains_never_panics(
|
||||
q0_x in -1000.0f32..1000.0,
|
||||
q0_y in -1000.0f32..1000.0,
|
||||
q1_x in -1000.0f32..1000.0,
|
||||
q1_y in -1000.0f32..1000.0,
|
||||
q2_x in -1000.0f32..1000.0,
|
||||
q2_y in -1000.0f32..1000.0,
|
||||
q3_x in -1000.0f32..1000.0,
|
||||
q3_y in -1000.0f32..1000.0,
|
||||
px in -2000.0f32..2000.0,
|
||||
py in -2000.0f32..2000.0,
|
||||
) {
|
||||
let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)];
|
||||
let p = pos2(px, py);
|
||||
let _ = crate::edit_text_ui::quad_contains(quad, p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_quad_does_not_contain_points() {
|
||||
let zero_quad = [pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0)];
|
||||
assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(10.0, 10.0)));
|
||||
assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ macro_rules! tl_ctx {
|
||||
}
|
||||
pub mod canvas;
|
||||
mod chrome;
|
||||
mod image_engine;
|
||||
pub use combine_grid::ThumbState as CombineThumb;
|
||||
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
|
||||
mod commands;
|
||||
@@ -45,13 +46,14 @@ mod menus;
|
||||
pub mod marks {
|
||||
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
|
||||
}
|
||||
pub use content_ui::TextDraft;
|
||||
pub use create_ui::Clip;
|
||||
pub use link_ui::LinkDraft;
|
||||
pub use optimize_ui::{OptimizeDraft, OptimizeTab};
|
||||
pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep};
|
||||
mod bidi;
|
||||
mod dialogs;
|
||||
pub use edit_text_ui::CropMode;
|
||||
pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection};
|
||||
mod editing;
|
||||
mod home;
|
||||
mod panels;
|
||||
@@ -77,6 +79,9 @@ mod state;
|
||||
mod tools;
|
||||
mod workflows;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod os_clipboard;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use app::same_file;
|
||||
pub(crate) use app::{MAX_AUTHOR_CHARS, copy_refused, date_text_for_pdf, print_refused};
|
||||
@@ -114,6 +119,7 @@ use panels::search_ui;
|
||||
pub use panels::side_panels;
|
||||
pub use persist::last_session;
|
||||
pub use persist::last_view;
|
||||
pub use persist::paper;
|
||||
use persist::recovery;
|
||||
pub use persist::units;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -140,7 +146,7 @@ use state::CopyPage;
|
||||
use state::OpenRequest;
|
||||
pub use state::{
|
||||
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt,
|
||||
PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt,
|
||||
PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt,
|
||||
};
|
||||
use state::{DocumentCopyJob, GridFile};
|
||||
pub use tools::bulk_fields;
|
||||
@@ -259,6 +265,8 @@ pub struct PdfCraftApp {
|
||||
pub view_defaults: canvas::ViewDefaults,
|
||||
/// The unit page sizes and margins are shown in (Preferences ▸ Documents and view).
|
||||
pub units: units::Unit,
|
||||
/// The paper Create ▸ Blank page makes (#5644): the system region's.
|
||||
pub blank_paper: paper::Paper,
|
||||
/// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid
|
||||
/// pattern (the session keeps the last valid one).
|
||||
pub date_format_draft: Option<String>,
|
||||
@@ -341,6 +349,8 @@ pub struct PdfCraftApp {
|
||||
/// tab coming to the front (Home, a document, Combine files) puts the view away.
|
||||
all_tools_tab: (Option<DocId>, bool),
|
||||
pub recent: Vec<RecentFile>,
|
||||
/// How many recent files to keep, 0 for no list (Preferences ▸ Documents and view, #5572).
|
||||
pub recent_limit: usize,
|
||||
/// Preferences: reopen the files that were open when PdfCraft last closed (#442).
|
||||
pub reopen_last_session: bool,
|
||||
/// Restore last view settings when reopening documents (Preferences ▸ Documents and view;
|
||||
@@ -380,6 +390,10 @@ pub struct PdfCraftApp {
|
||||
open_prepare_gate: Option<std::sync::Arc<open_worker::PrepareGate>>,
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
pub pinned: folders_ui::PinnedFolders,
|
||||
/// Tools pinned to Home, in user-defined order.
|
||||
pub pinned_tools: Vec<String>,
|
||||
/// Search query for the Pin a tool selection dialog.
|
||||
pub pin_tool_search: String,
|
||||
pub toast: Option<(String, f64)>,
|
||||
/// The form script message the toast shows now, if it shows one: drawn with its title and
|
||||
/// icon (#4293).
|
||||
@@ -545,8 +559,6 @@ pub struct PdfCraftApp {
|
||||
pub system_clipboard: bool,
|
||||
/// What ⌘V reads instead of the system clipboard (tests).
|
||||
pub clipboard_override: Option<create_ui::Clip>,
|
||||
/// V is down as a plain key press (see [`Self::paste_key_released`]).
|
||||
v_pressed: bool,
|
||||
/// Attach file: the file to attach instead of asking (tests, automation).
|
||||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
/// Duplicate Field: which field and onto which pages.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The one way to the operating system's clipboard.
|
||||
//!
|
||||
//! The system clipboard is a single process-wide resource: on Windows, two threads that
|
||||
//! open it at once can corrupt the heap. Every access goes through [`with`], which holds a
|
||||
//! lock across the whole open, use and close, so parallel callers (test threads, workers)
|
||||
//! take turns instead of racing.
|
||||
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Opens the system clipboard, runs `f` on it and closes it, with no other thread inside.
|
||||
pub(crate) fn with<T>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>) -> Result<T, arboard::Error> {
|
||||
let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
// Declared after the guard, so it's closed before the lock is released.
|
||||
let mut clipboard = arboard::Clipboard::new()?;
|
||||
f(&mut clipboard)
|
||||
}
|
||||
|
||||
/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists.
|
||||
pub(crate) fn has_text() -> bool {
|
||||
with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
/// The system clipboard as image paste sees it; text takes precedence over a cached image.
|
||||
pub(crate) fn for_image_paste() -> Option<crate::create_ui::Clip> {
|
||||
if has_text() { Some(crate::create_ui::Clip::Text(String::new())) } else { crate::create_ui::read_clipboard() }
|
||||
}
|
||||
@@ -241,6 +241,10 @@ pub(crate) fn show(
|
||||
}
|
||||
let mut page = usize::MAX;
|
||||
let mut group: Option<String> = None;
|
||||
// A card (or page header) out of view that was measured at this width only takes up its
|
||||
// height: laying out hundreds of them every frame put the panel over the frame budget
|
||||
// (#5488). Shorter lists are laid out whole, so every card stays in the accessibility tree.
|
||||
let virtual_list = roots.len() > LAY_OUT_WHOLE;
|
||||
for a in roots {
|
||||
if sort != SortBy::Page {
|
||||
let g = group_of(a);
|
||||
@@ -252,48 +256,118 @@ pub(crate) fn show(
|
||||
}
|
||||
} else if a.page != page {
|
||||
page = a.page;
|
||||
let n = page_counts.get(&page).copied().unwrap_or_default();
|
||||
let open = !view.comments.collapsed_pages.contains(&page);
|
||||
ui.add_space(6.0);
|
||||
let clicked = ui
|
||||
.horizontal(|ui| {
|
||||
let chevron = if open { "chevron-down" } else { "chevron-right" };
|
||||
let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked();
|
||||
let label = ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)]))
|
||||
.font(theme::semibold(12.5))
|
||||
.color(t.text_muted),
|
||||
)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())
|
||||
});
|
||||
toggle || label.clicked()
|
||||
})
|
||||
.inner;
|
||||
if clicked {
|
||||
if open {
|
||||
view.comments.collapsed_pages.push(page);
|
||||
} else {
|
||||
view.comments.collapsed_pages.retain(|&c| c != page);
|
||||
// Per document: two documents shown at the same width don't share heights.
|
||||
let header = ui.scope_id().with(("comment-page-header", view.id, page));
|
||||
if !(virtual_list && skip_if_hidden(ui, header)) {
|
||||
let top = ui.cursor().min.y;
|
||||
page_header(ui, t, info, page, page_counts.get(&page).copied().unwrap_or_default(), &mut view.comments.collapsed_pages);
|
||||
if virtual_list {
|
||||
remember_height(ui, header, top);
|
||||
}
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) {
|
||||
continue;
|
||||
}
|
||||
let key = (a.page, a.index);
|
||||
let thread = replies_of(a);
|
||||
let selected = view.comments.selected == Some(key);
|
||||
// A height is kept for what the card showed when it was measured: its document, what the
|
||||
// thread says, whether it was the selected card (which draws its menu and editor) and
|
||||
// whether an editor was open on it. An
|
||||
// edit, undo or redo that changes the card measures it afresh; other cards keep theirs.
|
||||
// A card with an editor open on it or on a reply is always drawn too (CodeRabbit on
|
||||
// #5583): a kept height would show it at its size before the editor opened.
|
||||
let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i)));
|
||||
let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing));
|
||||
// The selected card is always drawn, so it can scroll itself into view and keep its menu
|
||||
// and editor.
|
||||
if virtual_list && !selected && !editing && skip_if_hidden(ui, id) {
|
||||
continue;
|
||||
}
|
||||
let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation);
|
||||
let top = ui.cursor().min.y;
|
||||
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) {
|
||||
edit = Some(e);
|
||||
}
|
||||
if virtual_list {
|
||||
remember_height(ui, id, top);
|
||||
}
|
||||
}
|
||||
edit
|
||||
}
|
||||
|
||||
/// The "Page N" group header with its fold chevron and comment count `n`; a click folds or
|
||||
/// unfolds the page.
|
||||
fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: usize, collapsed: &mut Vec<usize>) {
|
||||
let open = !collapsed.contains(&page);
|
||||
ui.add_space(6.0);
|
||||
let clicked = ui
|
||||
.horizontal(|ui| {
|
||||
let chevron = if open { "chevron-down" } else { "chevron-right" };
|
||||
let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked();
|
||||
let label = ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", info.pages.get(page).map_or("?", |p| p.label.as_str()))]))
|
||||
.font(theme::semibold(12.5))
|
||||
.color(t.text_muted),
|
||||
)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
|
||||
toggle || label.clicked()
|
||||
})
|
||||
.inner;
|
||||
ui.add_space(2.0);
|
||||
if clicked {
|
||||
if open {
|
||||
collapsed.push(page);
|
||||
} else {
|
||||
collapsed.retain(|&c| c != page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a card's height depends on in the document: the comment and its replies as shown (their
|
||||
/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out
|
||||
/// alike at one width.
|
||||
fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for x in std::iter::once(a).chain(thread.iter().copied()) {
|
||||
(&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h);
|
||||
x.color.map(|c| c.map(f32::to_bits)).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Comment lists up to this long are laid out whole; longer ones lay out only what is in view.
|
||||
const LAY_OUT_WHOLE: usize = 100;
|
||||
|
||||
/// For a part of the list (a card or a page header) that was measured at this width and would
|
||||
/// fall out of view: takes up its height without laying it out, and returns `true`.
|
||||
fn skip_if_hidden(ui: &mut egui::Ui, id: egui::Id) -> bool {
|
||||
let width = ui.available_width();
|
||||
let Some(height) = ui.data(|d| d.get_temp::<(f32, f32)>(id)).filter(|(w, _)| (w - width).abs() < 0.5).map(|(_, h)| h) else {
|
||||
return false;
|
||||
};
|
||||
if ui.is_rect_visible(egui::Rect::from_min_size(ui.cursor().min, vec2(width, height))) {
|
||||
return false;
|
||||
}
|
||||
ui.allocate_space(vec2(width, height));
|
||||
true
|
||||
}
|
||||
|
||||
/// Records the height part `id` took since the cursor was at `top`, for [`skip_if_hidden`]: less
|
||||
/// the spacing `allocate_space` adds back after it.
|
||||
fn remember_height(ui: &egui::Ui, id: egui::Id, top: f32) {
|
||||
let height = ui.cursor().min.y - top - ui.spacing().item_spacing.y;
|
||||
if height.is_finite() && height > 0.0 {
|
||||
let width = ui.available_width();
|
||||
ui.data_mut(|d| d.insert_temp(id, (width, height)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment
|
||||
/// actions; returns an edit to apply.
|
||||
struct CardContext<'a> {
|
||||
|
||||
@@ -266,11 +266,27 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.into_iter()
|
||||
.map(|f| (f.resource, f.face))
|
||||
.collect();
|
||||
let shown = ed.font_shown(&sources);
|
||||
let fmt = ed.selection_formatting(&sources);
|
||||
ed.sync_selection_look(&fmt);
|
||||
let shown = fmt.font_shown;
|
||||
let mixed_size = fmt.size.is_none();
|
||||
// A selection aligns the rows it touches; the caret alone, the whole paragraph.
|
||||
let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false };
|
||||
let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs));
|
||||
let out = crate::format_text_ui::panel(
|
||||
ui,
|
||||
t,
|
||||
&mut ed.look,
|
||||
Some(&mut ed.extras),
|
||||
Fonts { sources: Some(&sources), shown, mixed_size },
|
||||
Some(paragraphs),
|
||||
);
|
||||
if out.size_committed {
|
||||
ed.choose_size(ed.look.size);
|
||||
}
|
||||
let mut changed = out.changed;
|
||||
if let Some(script) = out.script {
|
||||
changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script);
|
||||
}
|
||||
match out.font {
|
||||
Some(FontChoice::KeepSource) => {
|
||||
ed.keep_source_font();
|
||||
@@ -305,7 +321,13 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
let mut style = draft.style.clone();
|
||||
let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
|
||||
added_text_format(ui, t, &mut style, Some(paragraphs));
|
||||
let out = added_text_format(ui, t, &mut style, Some(paragraphs));
|
||||
if let Some(script) = out.script {
|
||||
let mut toggled = draft.clone();
|
||||
if crate::edit_text_ui::toggle_script(&mut toggled.text, &mut toggled.selection, script) {
|
||||
app.views[i].content.draft = Some(toggled);
|
||||
}
|
||||
}
|
||||
if style != draft.style {
|
||||
app.restyle_draft(i, style);
|
||||
}
|
||||
@@ -363,12 +385,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
/// Format text for added text: the standard families, size, colour, style and alignment
|
||||
/// (of the lines `paragraphs` selects, while the text is being typed).
|
||||
fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option<crate::format_text_ui::Paragraphs>) {
|
||||
fn added_text_format(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
style: &mut pdfcraft_engine::AddedText,
|
||||
paragraphs: Option<crate::format_text_ui::Paragraphs>,
|
||||
) -> crate::format_text_ui::Outcome {
|
||||
let shown = style.family.label().to_string();
|
||||
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs);
|
||||
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs);
|
||||
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
|
||||
style.family = family;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
|
||||
//! fonts already on its page and the spacing controls) and added text (the standard families,
|
||||
//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists,
|
||||
//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown
|
||||
//! paragraph spacing, or line spacing and scale for added text) is shown
|
||||
//! disabled and says so.
|
||||
//!
|
||||
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
|
||||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
|
||||
use pdfcraft_engine::text_script::Script;
|
||||
use pdfcraft_engine::{AddedText, FontFamily, TextAlign};
|
||||
|
||||
use crate::edit_text_ui::Extras;
|
||||
@@ -38,6 +39,7 @@ pub(crate) struct Fonts<'a> {
|
||||
pub sources: Option<&'a [(String, String)]>,
|
||||
/// The name the list shows for the current choice.
|
||||
pub shown: String,
|
||||
pub mixed_size: bool,
|
||||
}
|
||||
|
||||
/// Text being typed in, for the alignment buttons: with some of it selected, they show and set the
|
||||
@@ -85,7 +87,10 @@ impl Paragraphs<'_> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Outcome {
|
||||
pub changed: bool,
|
||||
pub size_committed: bool,
|
||||
pub font: Option<FontChoice>,
|
||||
/// Superscript or Subscript was clicked, for the selected paragraphs' text.
|
||||
pub script: Option<Script>,
|
||||
}
|
||||
|
||||
const SIZES: [f64; 16] = [4.0, 6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0];
|
||||
@@ -125,8 +130,9 @@ pub(crate) fn panel(
|
||||
widgets::section_title(ui, tl!("Format text"));
|
||||
|
||||
// The font, across the panel.
|
||||
ui.spacing_mut().combo_height = 360.0;
|
||||
let width = ui.available_width();
|
||||
craft_ui::ComboBox::from_id_salt("format-font")
|
||||
let font_resp = craft_ui::ComboBox::from_id_salt("format-font")
|
||||
.selected_text(fonts.shown.as_str())
|
||||
.width(width)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -152,31 +158,87 @@ pub(crate) fn panel(
|
||||
out.font = Some(FontChoice::Load);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) {
|
||||
Some(list) if list.is_empty() => {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
}
|
||||
Some(list) => {
|
||||
for font in list {
|
||||
if ui.button(&font.face).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path));
|
||||
ui.close();
|
||||
ui.menu_button(tl!("Installed fonts…"), |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
let reload = ui.button(tl!("Reload")).clicked();
|
||||
match crate::files::installed_font_files(ui.ctx(), reload) {
|
||||
Some(list) if list.is_empty() => {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
}
|
||||
Some(list) => {
|
||||
let search_id = ui.scope_id().with("installed-font-search");
|
||||
let mut query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, icon_rect, "search", 13.0, ui.visuals().text_color().gamma_multiply(0.6));
|
||||
let resp = ui
|
||||
.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search…")).desired_width(ui.available_width() - 8.0));
|
||||
if resp.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(search_id, query.clone()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let query_lower = query.trim().to_lowercase();
|
||||
let mut families: std::collections::BTreeMap<&str, Vec<&pdfcraft_engine::text_fonts::FontFile>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for font in &list {
|
||||
if query_lower.is_empty()
|
||||
|| font.family.to_lowercase().contains(&query_lower)
|
||||
|| font.face.to_lowercase().contains(&query_lower)
|
||||
{
|
||||
families.entry(&font.family).or_default().push(font);
|
||||
}
|
||||
}
|
||||
|
||||
if families.is_empty() {
|
||||
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().max_height(350.0).show(ui, |ui| {
|
||||
for (family, faces) in families {
|
||||
if faces.len() == 1 {
|
||||
if let Some(&font) = faces.first() {
|
||||
let label =
|
||||
if font.face != font.family && !font.face.is_empty() { font.face.as_str() } else { family };
|
||||
if ui.button(label).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.menu_button(family, |ui| {
|
||||
ui.set_min_width(180.0);
|
||||
for font in faces {
|
||||
let label =
|
||||
font.face.strip_prefix(family).map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&font.face);
|
||||
if ui.button(label).clicked() {
|
||||
out.font = Some(FontChoice::Installed(font.path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(tl!("Finding document fonts…"));
|
||||
None => {
|
||||
ui.label(tl!("Finding document fonts…"));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available.")));
|
||||
.response;
|
||||
font_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Font")));
|
||||
font_resp.on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available.")));
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Size and colour | bullets, numbering and alignment.
|
||||
let mut size_committed = false;
|
||||
row(ui, |ui| {
|
||||
font_size_input(ui, &mut look.size);
|
||||
size_committed = font_size_input(ui, &mut look.size, fonts.mixed_size);
|
||||
let swatch = colour_button(ui, t, look.color);
|
||||
egui::Popup::menu(&swatch).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
|
||||
@@ -241,8 +303,17 @@ pub(crate) fn panel(
|
||||
}
|
||||
None => planned(ui, "underline", tl!("Underline")),
|
||||
}
|
||||
planned(ui, "superscript", tl!("Superscript"));
|
||||
planned(ui, "subscript", tl!("Subscript"));
|
||||
if let Some(p) = ¶graphs {
|
||||
for (script, icon, tip) in [(Script::Super, "superscript", tl!("Superscript")), (Script::Sub, "subscript", tl!("Subscript"))] {
|
||||
let on = pdfcraft_engine::text_script::is_in(p.text, p.selection.as_ref(), script);
|
||||
if icons::button(ui, icon, 30.0, on, tip).clicked() {
|
||||
out.script = Some(script);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
planned(ui, "superscript", tl!("Superscript"));
|
||||
planned(ui, "subscript", tl!("Subscript"));
|
||||
}
|
||||
});
|
||||
|
||||
// Line and paragraph spacing; horizontal scale and character spacing: two columns, each an
|
||||
@@ -256,12 +327,17 @@ pub(crate) fn panel(
|
||||
labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing"));
|
||||
let own = tl!("Auto").to_string();
|
||||
let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) };
|
||||
craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str());
|
||||
for v in LINE_SPACINGS {
|
||||
ui.selectable_value(&mut e.line_spacing, v, number(v));
|
||||
}
|
||||
});
|
||||
let spacing_resp = craft_ui::ComboBox::from_id_salt("format-line-spacing")
|
||||
.selected_text(shown)
|
||||
.width(half)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str());
|
||||
for v in LINE_SPACINGS {
|
||||
ui.selectable_value(&mut e.line_spacing, v, number(v));
|
||||
}
|
||||
})
|
||||
.response;
|
||||
spacing_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Line spacing")));
|
||||
ui.add_space(8.0);
|
||||
ui.add_enabled_ui(false, |ui| {
|
||||
labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing"));
|
||||
@@ -290,7 +366,8 @@ pub(crate) fn panel(
|
||||
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
|
||||
});
|
||||
|
||||
out.changed = (look.clone(), extras.as_deref().copied()) != before;
|
||||
out.size_committed = size_committed;
|
||||
out.changed = out.changed || size_committed || (look.clone(), extras.as_deref().copied()) != before;
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
out
|
||||
@@ -351,7 +428,8 @@ fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width:
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix);
|
||||
let height = ui.spacing().interact_size.y;
|
||||
ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0);
|
||||
let drag_resp = ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0);
|
||||
drag_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::SpinButton, ui.is_enabled(), label.1));
|
||||
let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1);
|
||||
egui::Popup::menu(&list).show(|ui| {
|
||||
for &v in presets {
|
||||
@@ -378,31 +456,50 @@ fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Respon
|
||||
|
||||
/// Commit a typed size once (Enter or leaving the field), rather than one undo step per key.
|
||||
/// The bounds match AddedText validation, so the panel also accepts coloured text below 8 pt.
|
||||
fn font_size_input(ui: &mut egui::Ui, size: &mut f64) {
|
||||
fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool {
|
||||
let mut committed = false;
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let id = ui.make_persistent_id("font-size-input");
|
||||
let (previous, mut text) = ui.data(|d| d.get_temp::<(u64, String)>(id)).unwrap_or_else(|| (size.to_bits(), size.to_string()));
|
||||
if previous != size.to_bits() {
|
||||
text = size.to_string();
|
||||
let initial_text = || {
|
||||
if mixed || *size <= 0.0 { tl!("Mixed").to_string() } else { size.to_string() }
|
||||
};
|
||||
// A selection can become mixed without changing its representative numeric size.
|
||||
let (previous, previous_mixed, mut text) =
|
||||
ui.data(|d| d.get_temp::<(u64, bool, String)>(id)).unwrap_or_else(|| (size.to_bits(), mixed, initial_text()));
|
||||
if previous != size.to_bits() || previous_mixed != mixed {
|
||||
text = initial_text();
|
||||
}
|
||||
let field = egui::TextEdit::singleline(&mut text).id(id).desired_width(40.0);
|
||||
let response = ui.add_sized([40.0, ui.spacing().interact_size.y], field).accessible_name(tl!("Font size:"));
|
||||
// Reserve the localized mixed label, field margins and caret without resizing while typing.
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let margin = egui::Margin::from(ui.spacing().button_padding);
|
||||
let mixed_width = ui.painter().layout_no_wrap(tl!("Mixed").into(), font.clone(), ui.visuals().text_color()).size().x;
|
||||
let width = (mixed_width + margin.sum().x + 2.0).ceil().max(40.0);
|
||||
let field = egui::TextEdit::singleline(&mut text).id(id).font(font).margin(margin).desired_width(width);
|
||||
let response = ui.add_sized([width, ui.spacing().interact_size.y], field).accessible_name(tl!("Font size:"));
|
||||
let cancel = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if cancel {
|
||||
text = size.to_string();
|
||||
text = initial_text();
|
||||
response.surrender_focus();
|
||||
} else if response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
if let Some(value) = typed_font_size(&text) {
|
||||
if mixed || *size != value {
|
||||
committed = true;
|
||||
}
|
||||
*size = value;
|
||||
text = size.to_string();
|
||||
} else {
|
||||
text = initial_text();
|
||||
}
|
||||
text = size.to_string();
|
||||
}
|
||||
let before = *size;
|
||||
let list = icons::button(ui, "chevron-down", PRESET_LIST, false, tl!("Font size"));
|
||||
egui::Popup::menu(&list).show(|ui| {
|
||||
for value in SIZES {
|
||||
if ui.selectable_label(*size == value, format!("{value} pt")).clicked() {
|
||||
if mixed || *size != value {
|
||||
committed = true;
|
||||
}
|
||||
*size = value;
|
||||
ui.close();
|
||||
}
|
||||
@@ -411,8 +508,9 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64) {
|
||||
if *size != before {
|
||||
text = size.to_string();
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, (size.to_bits(), text)));
|
||||
ui.data_mut(|d| d.insert_temp(id, (size.to_bits(), mixed, text)));
|
||||
});
|
||||
committed
|
||||
}
|
||||
|
||||
fn typed_font_size(text: &str) -> Option<f64> {
|
||||
@@ -447,12 +545,87 @@ mod typed_size_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_size_label_fits_the_themed_panel() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut ready = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(272.0, 500.0)).with_accessibility_check(false).build_ui_state(
|
||||
move |ui, look: &mut AddedText| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), crate::theme::ThemeKind::Light);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
let fonts = Fonts { sources: Some(&[]), shown: tl!("Mixed").into(), mixed_size: true };
|
||||
panel(ui, &Tokens::get(ui.ctx()), look, None, fonts, None);
|
||||
},
|
||||
AddedText::default(),
|
||||
);
|
||||
h.run_steps(4);
|
||||
let field = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Font size:");
|
||||
let style = h.ctx.global_style();
|
||||
let font = egui::TextStyle::Body.resolve(&style);
|
||||
let width = h.ctx.fonts_mut(|fonts| fonts.layout_no_wrap(tl!("Mixed").into(), font, Color32::BLACK).size().x);
|
||||
let padding = 2.0 * style.spacing.button_padding.x;
|
||||
assert!(field.rect().width() - padding >= width, "Mixed must fit inside the input margins");
|
||||
assert!(h.get_by_label("Alignment").rect().right() <= 272.0, "the wider size field must leave room for the rest of the format row");
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_MIXED_PANEL_SHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_size_changes_refresh_the_field_without_discarding_a_draft() {
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, state: &mut (f64, bool, usize)| {
|
||||
if font_size_input(ui, &mut state.0, state.1) {
|
||||
state.1 = false;
|
||||
state.2 += 1;
|
||||
}
|
||||
},
|
||||
(12.0, false, 0),
|
||||
);
|
||||
h.run();
|
||||
let field =
|
||||
|h: &egui_kittest::Harness<'_, (f64, bool, usize)>| h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Font size:").value();
|
||||
assert_eq!(field(&h).as_deref(), Some("12"));
|
||||
h.state_mut().0 = 24.0;
|
||||
h.run_steps(2);
|
||||
assert_eq!(field(&h).as_deref(), Some("24"), "the heading selection updates the size");
|
||||
h.state_mut().1 = true;
|
||||
h.run_steps(2);
|
||||
assert_eq!(field(&h).as_deref(), Some("Mixed"), "collapsing to a mixed block must refresh even when its representative size stays 24");
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_MIXED_SIZE_SHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
h.state_mut().1 = false;
|
||||
h.run_steps(2);
|
||||
assert_eq!(field(&h).as_deref(), Some("24"), "selecting the heading again clears Mixed without a numeric change");
|
||||
h.state_mut().1 = true;
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Font size:").click();
|
||||
h.run();
|
||||
h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||||
h.event(egui::Event::Text("1".into()));
|
||||
h.run_steps(3);
|
||||
assert_eq!(field(&h).as_deref(), Some("1"), "an unchanged mixed style must preserve the draft");
|
||||
h.event(egui::Event::Text("8".into()));
|
||||
h.run_steps(3);
|
||||
assert_eq!(field(&h).as_deref(), Some("18"));
|
||||
assert_eq!(*h.state(), (24.0, true, 0), "typing does not apply a formatting change");
|
||||
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(*h.state(), (18.0, false, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_size_commits_once_and_invalid_input_preserves_the_style() {
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, state: &mut (f64, usize)| {
|
||||
let before = state.0;
|
||||
ui.horizontal(|ui| font_size_input(ui, &mut state.0));
|
||||
ui.horizontal(|ui| font_size_input(ui, &mut state.0, false));
|
||||
if state.0 != before {
|
||||
state.1 += 1;
|
||||
}
|
||||
@@ -481,7 +654,7 @@ mod typed_size_tests {
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, state: &mut (f64, usize)| {
|
||||
let before = state.0;
|
||||
ui.horizontal(|ui| font_size_input(ui, &mut state.0));
|
||||
ui.horizontal(|ui| font_size_input(ui, &mut state.0, false));
|
||||
let _ = ui.button("Other control");
|
||||
if state.0 != before {
|
||||
state.1 += 1;
|
||||
|
||||
@@ -78,7 +78,10 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.add_space(2.0);
|
||||
let shown = if app.all_tools_expanded { TOOL_GROUPS.len() } else { COLLAPSED_TOOLS.min(TOOL_GROUPS.len()) };
|
||||
for g in &TOOL_GROUPS[..shown] {
|
||||
if tool_row(ui, t, g).clicked() {
|
||||
let (resp, pin_clicked) = tool_row(ui, t, g, app.is_tool_pinned(g.id));
|
||||
if pin_clicked {
|
||||
app.toggle_pin_tool(g.id);
|
||||
} else if resp.clicked() {
|
||||
app.select_tool(g.id);
|
||||
}
|
||||
}
|
||||
@@ -110,21 +113,29 @@ pub(super) fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color
|
||||
}
|
||||
|
||||
/// Draw one tool-group row with its icon, label, badge and availability hint.
|
||||
fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, is_pinned: bool) -> (egui::Response, bool) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
|
||||
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,
|
||||
|
||||
let pin_rect = Rect::from_center_size(body.right_center() - vec2(14.0, 0.0), vec2(22.0, 22.0));
|
||||
let pin_resp = ui.interact(pin_rect, ui.scope_id().with(("pin_toggle_row", g.id)), Sense::click());
|
||||
let pin_hovered = pin_resp.hovered();
|
||||
let mut pin_clicked = false;
|
||||
|
||||
let pin_space = 26.0;
|
||||
let badge_or_hint_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) + 18.0,
|
||||
(None, Availability::Planned(m)) if resp.hovered() => {
|
||||
let hint = format!("{} · {m}", tl!("Planned"));
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(hint, theme::medium(10.5), t.text_faint).size().x) + 18.0
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(hint, theme::medium(10.5), t.text_faint).size().x) + 10.0
|
||||
}
|
||||
_ => 8.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let right_reserved = pin_space + badge_or_hint_reserved + 8.0;
|
||||
let clipped =
|
||||
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) {
|
||||
@@ -132,14 +143,14 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
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(body.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(pin_space + w / 2.0 + 4.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(
|
||||
body.right_center() - vec2(10.0, 0.0),
|
||||
body.right_center() - vec2(pin_space + 4.0, 0.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("{} · {m}", tl!("Planned")),
|
||||
theme::medium(10.5),
|
||||
@@ -148,13 +159,34 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let row_hovered = ui.rect_contains_pointer(rect) || resp.hovered();
|
||||
if is_pinned || row_hovered || pin_hovered {
|
||||
if pin_hovered {
|
||||
ui.painter().rect_filled(pin_rect, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
let pin_tint = if is_pinned {
|
||||
t.accent_text
|
||||
} else if pin_hovered {
|
||||
t.text
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
icons::paint(ui, pin_rect.shrink(3.0), "pin", 15.0, pin_tint);
|
||||
let tip = if is_pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
|
||||
pin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
|
||||
if theme::hand(pin_resp).on_hover_text(tip).clicked() {
|
||||
pin_clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
let tip = match g.availability {
|
||||
Availability::Ready => tl!("Available").to_string(),
|
||||
Availability::Planned(m) => format!("{} {m} — {}", tl!("Planned for milestone"), tl!("open to see what it will include")),
|
||||
Availability::Provider => tl!("Optional: needs an AI provider you configure").into(),
|
||||
};
|
||||
let tip = if clipped { format!("{} — {tip}", tl!(g.label)) } else { tip };
|
||||
theme::hand(resp).on_hover_text(tip)
|
||||
(theme::hand(resp).on_hover_text(tip), pin_clicked)
|
||||
}
|
||||
|
||||
/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod last_session;
|
||||
pub mod last_view;
|
||||
pub mod paper;
|
||||
pub(super) mod recovery;
|
||||
pub mod units;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -98,7 +98,7 @@ impl PdfCraftApp {
|
||||
/// the Recovery dialog offers, so neither opens twice.
|
||||
///
|
||||
/// Only one password prompt can wait at a time: the first encrypted file asks for its
|
||||
/// password and later ones stay closed (they are in the recent-files list).
|
||||
/// password and later ones stay closed (in the recent-files list, if there is one).
|
||||
pub fn reopen_last_files(&mut self, skip: &[String]) {
|
||||
let last = std::mem::take(&mut self.last_session);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user