diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cab750afa6..40626dc6ba 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (288) +## In this repository (289) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -28,6 +28,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index e5688a2f67..383272f3f7 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -212,6 +212,16 @@ licence_file = "LICENSE-MIT" kind = "translation" usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +[[asset]] +path = "crates/ui-egui/src/i18n/fa.tsv" +title = "Persian (Farsi) interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" + [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" diff --git a/Cargo.lock b/Cargo.lock index 8b49c9521c..37f2487f8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-quit", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3972,7 +3972,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3994,7 +3994,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4016,7 +4016,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4034,11 +4034,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-content" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4046,7 +4046,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "hayro-syntax", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "md-5", @@ -4089,7 +4089,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4104,7 +4104,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-docpath", @@ -4145,14 +4145,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "proptest", @@ -4162,7 +4162,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.13.0" +version = "0.14.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4185,11 +4185,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-js" -version = "0.13.0" +version = "0.14.0" dependencies = [ "boa_engine", "chrono", @@ -4197,7 +4197,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4210,14 +4210,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4227,7 +4227,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-organize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", @@ -4361,11 +4361,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -4425,7 +4425,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "image", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index e7acfd31f9..78c7a78c6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.13.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.13.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.13.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.13.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.13.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.13.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.13.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" } -pdfcraft-model = { path = "crates/model", version = "0.13.0" } -pdfcraft-content = { path = "crates/content", version = "0.13.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.13.0" } -pdfcraft-print = { path = "crates/print", version = "0.13.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.13.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" } -pdfcraft-js = { path = "crates/js", version = "0.13.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.13.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" } -pdfcraft-export = { path = "crates/export", version = "0.13.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.13.0" } -pdfcraft-create = { path = "crates/create", version = "0.13.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.13.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" } -pdfcraft-render = { path = "crates/render", version = "0.13.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.13.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.13.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.14.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.14.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.14.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.14.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.14.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" } +pdfcraft-model = { path = "crates/model", version = "0.14.0" } +pdfcraft-content = { path = "crates/content", version = "0.14.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.14.0" } +pdfcraft-print = { path = "crates/print", version = "0.14.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.14.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" } +pdfcraft-js = { path = "crates/js", version = "0.14.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.14.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" } +pdfcraft-export = { path = "crates/export", version = "0.14.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.14.0" } +pdfcraft-create = { path = "crates/create", version = "0.14.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.14.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" } +pdfcraft-render = { path = "crates/render", version = "0.14.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.14.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.14.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/ROADMAP.md b/ROADMAP.md index f3515c2eb1..edc0ad434a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. +- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index d0b0b69fd2..c860298d98 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -188,8 +188,10 @@ impl Automation { i.flip_v = !i.flip_v; } if let Some(c) = rect_arg(a, "crop")? { - if !c.iter().all(|v| (0.0..0.5).contains(v)) { - return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)")); + if !pdfcraft_engine::AddedImage::crop_valid(c) { + return Err(bad( + "crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95", + )); } i.crop = c; } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e94354021f..521600edb3 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -174,6 +174,12 @@ impl Automation { self } + /// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`]. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor); + self + } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4fb3d857ac..51d46dbc65 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -627,7 +627,7 @@ pub fn tools() -> Vec { "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, "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.49 }, "minItems": 4, "maxItems": 4 }, + "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." }, "image": { "type": "string", "description": "Replace the picture with this file." }, }), &["doc", "page", "index"], @@ -956,7 +956,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f226170928..ba78475c22 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -252,6 +252,19 @@ fn doc_info_reports_the_open_destination() { "{open}" ); assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0)); + // The initial view reads the named destination's page, and choosing page 1 replaces it (ISO + // 32000-2 §12.3.2.4): one undo step, kept by the save. + let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone(); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 2); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(page(&mut a, saved), 1); let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap(); assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null()); } @@ -4471,6 +4484,12 @@ fn adding_content_through_tools() { assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); // Image tools: rotate, flip, crop, replace. ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] })); + // One side may trim most of the picture; together an axis keeps at least 5 %. + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] })); + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })), + Err(ToolError::InvalidArgs(_)) + )); ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" })); assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true); assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_)))); @@ -5842,6 +5861,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a laid-out XFA form's signature box through the tools signs the field named by its +/// SOM path, which is how another viewer, laying the form out again, finds the signature; the +/// signed file reopens with the signature intact and on that field. +#[test] +fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { + let dir = workdir("xfa-sign"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let som = "form[0].page1[0].sign[0]"; + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}"); + ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" })); + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}")); + assert_eq!(s["field"], som, "{s}"); + assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); @@ -6848,12 +6889,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET"; assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. }))); } +/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an +/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves. #[test] -fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() { +fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() { + let dir = workdir("italic-run-word"); + let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \ +BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("opinion.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" })); + let zip = std::fs::read(dir.join("opinion.docx")).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + // The paragraphs that hold text, each as its runs: (text, italic). + let paragraphs: Vec> = xml + .split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| Some((r.split_once("")?.1.split_once("")?.0.to_string(), r.contains("")))) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect(); + let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}")); + assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}"); + assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::(), "Before Chevron after.", "{xml}"); + assert_eq!(sentence.iter().map(|(_, i)| *i).collect::>(), [false, true, false], "{xml}"); + assert_eq!(paragraphs.len(), 2, "{xml}"); +} + +/// Hundreds of calculations run on opening and again after a change through the tools. The +/// time limits are raised so that a busy machine does not stop them (the engine's tests check +/// that the limits stop them). +#[test] +fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() { let dir = workdir("xfa-many"); let (sections, rows) = (10, 20); std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap(); - let mut a = auto(&dir); + let mut a = auto(&dir).with_xfa_time_scale(100); let started = std::time::Instant::now(); let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" })); let open_took = started.elapsed(); @@ -7276,7 +7384,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { let dir = workdir("xfa-print-buttons"); let template = pdfcraft_xfa::fixtures::repeated_print_button_template(); std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); - let mut a = auto(&dir); + // Raised time limits: a busy machine does not stop the click scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap(); let check = |a: &mut Automation, doc| { let fields = ok(a, "form_fields", json!({ "doc": doc })); @@ -7301,7 +7410,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); - let mut a = auto(&dir); + // Raised time limits: a busy machine does not stop the scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap(); let field = |a: &mut Automation, n: &str| { let f = ok(a, "form_fields", json!({ "doc": doc })); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 4157c57e40..d292c0869f 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -149,7 +149,8 @@ pub struct AddedImage { pub rotation: u8, pub flip_h: bool, pub flip_v: bool, - /// The fraction trimmed from each side of the image: left, bottom, right, top. + /// The fraction trimmed from each side of the image: left, bottom, right, top (see + /// [`AddedImage::crop_valid`]). pub crop: [f64; 4], } @@ -158,12 +159,43 @@ impl AddedImage { AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } } + /// The least of the image's width and of its height that a crop keeps. + pub const MIN_CROP_KEEP: f64 = 0.05; + + /// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is + /// `0.9500000000000001` in floating point and still keeps 5 %. + const CROP_ROUNDING: f64 = 1e-9; + + /// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least + /// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite + /// trims nothing. + pub fn crop_valid(crop: [f64; 4]) -> bool { + let [l, b, r, t] = crop; + let most = 1.0 - Self::MIN_CROP_KEEP; + crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING + } + + /// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side + /// in range, and an axis that trims too much keeps its two sides' proportion. + pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] { + let most = 1.0 - Self::MIN_CROP_KEEP; + let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion; + // a pair within the limit keeps each side within it too. + let fit = |a: f64, c: f64| { + let half = a / 2.0 + c / 2.0; + if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) } + }; + let ((l, r), (b, t)) = (fit(l, r), fit(b, t)); + [l, b, r, t] + } + /// The matrix from image space (the unit square) to display space: crop, flip, rotate, /// then fill the box. fn matrix(&self) -> pdfcraft_content::Matrix { use pdfcraft_content::Matrix as M; - let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45)); - let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05)); + let [l, b, r, t] = Self::clamp_crop(self.crop); + let (cw, ch) = (1.0 - l - r, 1.0 - b - t); let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); if self.flip_h { m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); @@ -1225,6 +1257,21 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<( Ok(()) } +/// Leave only item `index` on `page`: its contents become the item's own stream and its +/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels +/// alone. For previews on a copy of the document; the page's resources stay as they are. +pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let a = find(doc, page, index)?; + let p = page_list(doc).swap_remove(page); + // An item's stream starts from the page's initial graphics state: `place_tagged` closes + // everything drawn before it, so it draws the same on its own. + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)])); + d.remove(b"Annots"); + })?; + Ok(()) +} + /// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped` /// names them so), so they go when the item is replaced or deleted. fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 6f14271be4..5180e76018 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -539,6 +539,11 @@ pub(crate) fn selectable_images( /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. +/// +/// Under a clip set outside every scope that holds only this image (a report or table drawing +/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active +/// clip. The outermost scope holding only the image is used then: it contains all of the +/// image's private clips, which move with it, while the outer clip is shared and stays. fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { struct Scope { start: usize, @@ -556,6 +561,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { @@ -566,9 +572,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); + if scope.selected && !scope.other_paint && !scope.inherited_path { + if !scope.inherited_clip { + // Prefer the innermost scope that contains every active clipping path. + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some((scope.start, index, scope.matrix)); } matrix = scope.matrix; clipped = scope.inherited_clip; @@ -611,7 +621,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result>` page number, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, /// `<>`, dates `<>`, `<>`, `<>`, `<>`, - /// `<>`, `<>`, `<>`, `<>`, and Bates numbers `<>` + /// `<>`, `<>`, `<>`, `<>`, the dotted day-first + /// dates of German-speaking and much of Central and Eastern Europe `<>`, + /// `<>`, `<>`, `<>`, and Bates numbers `<>` /// (digits, start, prefix, suffix). pub text: [String; 6], /// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier. @@ -297,6 +299,10 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C "d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)), "d/m/yyyy" => format!("{d}/{m}/{y}"), "dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"), + "d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)), + "d.m.yyyy" => format!("{d}.{m}.{y}"), + "dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)), + "dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"), "yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"), "yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"), "yyyy/m/d" => format!("{y}/{m}/{d}"), @@ -1038,8 +1044,8 @@ pub use text::{ }; pub mod added; pub use added::{ - Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers, - update_content, + Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, + scan_markers, update_content, }; #[cfg(test)] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3ad46e8643..0f8adc6514 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() { assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none()); } +#[test] +fn an_added_image_crop_keeps_some_of_each_axis() { + use crate::added::AddedImage; + assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95])); + assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5])); + // 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is. + assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]); + assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across"); + assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across"); + assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0])); + assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is"); + // From a file: too much across keeps the two sides' proportion; nonsense trims nothing. + let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0); + // Sides beyond the whole image keep their proportion too, and huge ones don't overflow. + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]); + assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]); + assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit"); +} + #[test] fn tokens_expand() { let c = cx(); @@ -82,6 +107,7 @@ fn tokens_expand() { assert_eq!(expand("<<1 of n>> · <> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5"); assert_eq!(expand("<> <> <>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026"); assert_eq!(expand("<> <>", 1, 1, 0, &c), "2026/10/01 2026/10/1"); + assert_eq!(expand("<> <> <> <>", 1, 1, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26"); assert_eq!(expand("<>", 1, 1, 104, &c), "ABC000104-X"); assert_eq!(expand("keep <> and <> and <>"), Some(100)); @@ -983,6 +1009,28 @@ fn added_text_and_images_are_page_content_that_stays_editable() { assert!(marks_present(&doc).is_empty()); } +/// #4464: a page left with one added item draws that item and nothing else. +#[test] +fn an_isolated_item_is_all_that_is_left_on_its_page() { + let mut doc = fixture(); + let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() }; + let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() }; + add_content(&mut doc, 0, &Content::Text(first)).unwrap(); + add_content(&mut doc, 0, &Content::Text(second)).unwrap(); + let before = streams(&doc, 0).join("\n"); + assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}"); + assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item"); + assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap(); + isolate_content(&mut doc, 0, 1).unwrap(); + let after = streams(&doc, 0).join("\n"); + assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it"); + assert_eq!(list_added(&doc).len(), 1); +} + /// Edit ▸ Paste (#2636): where a pasted item lands. #[test] fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() { @@ -4978,6 +5026,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1); } +/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the +/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture +/// used to show only through its old box) while the section's clip and text stay put. +#[test] +fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() { + // The picture's own clip as wide as the picture, and wider (both seen in one report). + for clip_width in [61.2, 85.0] { + let mut doc = image_page_with(&format!( + "q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q" + )); + let before = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect); + let caption = text::text_lines(&doc, 0).unwrap(); + let target = [162.0, 79.0, 284.4, 135.6]; + let transform = images::rect_to_rect(before.visible_rect.unwrap(), target); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap()[0].clone(); + let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05); + assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect); + assert_eq!(after.object, before.object, "pixels are never resampled or replaced"); + assert_eq!( + text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + "the section's text stays where it was" + ); + // The section's clip is shared, so it still bounds the picture: past it, nothing shows. + let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap(); + let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap(); + assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}"); + } +} + #[test] fn private_image_crop_with_intervening_state_resizes_across_streams() { let variants: Vec> = vec![ diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 1326ccb5e8..044e40e2e2 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -220,7 +220,10 @@ impl crate::Document { .filter(|b| !b.text.trim().is_empty()) .map(|b| { let f = b.base_font.to_ascii_lowercase(); + // `text` is trimmed; the editor text keeps the source's own spaces. + let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)]; pdfcraft_export::Block { + space_around, text: b.text, rect: visible_rect(b.rect), size: b.size * unit, diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 606ca53c65..c9382c1b02 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -63,7 +63,7 @@ const CKB_DAYS: [&str; 7] = ["یەکشەممە", "دووشەممە", "سێشەم /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 34] = [ +pub static DATE_LANGUAGES: [DateLanguage; 35] = [ DateLanguage { code: "en", name: "English", @@ -267,6 +267,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 34] = [ days: CKB_DAYS, days_short: CKB_DAYS, }, + // Persian (Farsi); Gregorian month and weekday names, right to left like Arabic and Kurdish. + // Persian does not change the month form after a day number, so `months_with_day` is `None`. + DateLanguage { + code: "fa", + name: "فارسی", + months: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + months_with_day: None, + months_short: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + days: ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"], + days_short: ["یک", "دو", "سه", "چه", "پن", "جم", "شن"], + }, DateLanguage { code: "pl", name: "Polski", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 84c809092e..87032319a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2468,6 +2468,8 @@ struct EditCtx { xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + xfa_time_scale: u32, /// The edit took the XFA out of the form: what went with it, for the document's warnings. xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. @@ -2502,6 +2504,7 @@ impl EditCtx { xfa_out: Default::default(), xfa_datasets: None, xfa_ran_away: false, + xfa_time_scale: 1, xfa_removed: Vec::new(), pages: 0, date: now.map(pdfcraft_cos::pdf_date), @@ -2684,7 +2687,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> }, Edit::XfaEvent { som, activity } => { let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); cx.xfa_datasets = run.datasets; cx.xfa_ran_away |= run.ran_away; @@ -3245,6 +3249,9 @@ pub struct Session { date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + xfa_time_scale: Option, } /// Immutable session state needed while a document is prepared away from its owning thread. @@ -3253,6 +3260,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + xfa_time_scale: u32, } /// A fully prepared document that has not yet been registered in a session. @@ -3489,7 +3497,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// fields take the data's values. fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); cx.xfa_datasets = run.datasets; @@ -3532,9 +3541,14 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { let data = xfa_field_data(doc); let fields = pdfcraft_forms::fields(doc); - let mut changed = Vec::new(); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = fields.iter().find(|f| f.name == name) else { continue }; + let Some(f) = by_name.get(name.as_str()) else { continue }; let new = match value { pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { // Saved values, one per line for a multi-select list; shown text stands for @@ -3552,13 +3566,14 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), pdfcraft_xfa::FieldData::None => None, }; - if let Some(v) = new - && pdfcraft_forms::set_value(doc, &name, &v).is_ok() - { - changed.push(name); + if let Some(v) = new { + updates.push((name, v)); } } - Ok(changed) + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) } /// Validate the signature fields of `cos` (written as `bytes`). @@ -3577,6 +3592,17 @@ impl Session { self } + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + /// A real clock for a host that has neither a clock nor a time zone of its own (a /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in @@ -3649,7 +3675,7 @@ impl Session { /// Snapshot the session state that affects open-time scripts, dates and signature trust. pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } } /// Prepare a document without adding it to this session. @@ -3778,7 +3804,13 @@ impl Session { let mut work = cos.clone(); let pages = info.pages.len(); let ran = guard(|| { - let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false }; + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; let changes = xfa::on_open(&mut work, &tpl, &mut run); if run.ran_away { ran_away = true; @@ -3962,6 +3994,7 @@ impl Session { let now = self.now(); let today = self.today(); let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); let name = doc.name.clone(); @@ -3971,6 +4004,7 @@ impl Session { if !js_off { cx.xfa = doc.xfa_template.clone(); } + cx.xfa_time_scale = xfa_time_scale; cx.pages = doc.info.pages.len(); cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); @@ -4068,7 +4102,13 @@ impl Session { let mut reset = false; collect(&edit, &mut changed, &mut reset); if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; // Every changed field's scripts, then the calculations once (after a reset, // only those). let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ba6f2c1c7b..360517a87c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3650,8 +3650,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() { assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped"); img.flip_h = false; img.crop = [0.0, 0.0, 0.5, 0.0]; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); + // More than half from one side: only the blue right part is left. + img.crop = [0.7, 0.0, 0.0, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge"); } #[test] @@ -5580,6 +5584,74 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA form whose values are only in its datasets takes every one on opening, as +/// filling the fields one at a time would leave it: values, appearances and the note; a +/// read-only field keeps its own value. +#[test] +fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { + let n = 60; + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("many.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // The read-only first field keeps its own (empty) value, and is not named. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}"); + assert!(!note.contains("f0[0]"), "{note}"); + // The same values filled one at a time. + let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap(); + for i in 0..n { + let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}"))); + assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}"); + } + let opened = &doc.editor.as_ref().unwrap().cos; + let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec, String)> { + pdfcraft_forms::fields(d) + .into_iter() + .map(|f| { + let w = f.widgets.first().map(|w| w.obj).unwrap(); + let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a)); + let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n)); + let look = match n.as_deref() { + Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(), + _ => String::new(), + }; + (f.name, f.value, look) + }) + .collect() + }; + let got = shown(opened); + assert_eq!(got.len(), n); + assert_eq!(got, shown(&one_by_one)); + assert!(got[0].1.is_empty()); + assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})")))); +} + +/// 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). +#[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 = 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)"); +} + /// A date goes into the XFA data in the field's data picture (``), as other /// viewers write and read it, and comes back from there when the form is opened again. It used /// to be written as an ISO date whatever the picture said. @@ -5663,6 +5735,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); } +/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take +/// values that scripts read, and are saved into the datasets. #[test] fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { let data = "
JapanEnglish\nSpanish
"; @@ -5682,7 +5756,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]); assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT)); assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD)); - assert_eq!(field(&s, "sign").kind, K::Signature); + // The signature field is named by its SOM path, as another viewer finds a signature. + assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature); // Scripts read a choice list's saved value. assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id)); // Choosing another item recalculates; the list takes several values; a typed value is kept @@ -5840,7 +5915,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_ #[test] fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - let mut s = Session::new().with_clock(|| 1_700_000_000); + // Raised time limits: a busy machine does not stop the calculations this checks. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); let id = s.open("rows.pdf", None, bytes, None).expect("opens"); let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); @@ -5869,16 +5945,19 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert!(String::from_utf8_lossy(&out).contains("only")); } -/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within -/// the opening's budget, and a change recalculates them within the event's. +/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine, and a +/// change recalculates them in one more. The time limits are raised so that a busy machine +/// does not stop them; the next test checks that the limits stop them. #[test] -fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { +fn hundreds_of_xfa_calculations_run_in_one_engine() { let (sections, rows) = (10, 20); let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); - let mut s = Session::new().with_clock(|| 1_700_000_000); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let engines = pdfcraft_js::xfa_session::engines_started(); let started = std::time::Instant::now(); let id = s.open("many.pdf", None, bytes, None).expect("opens"); let elapsed = started.elapsed(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 1, "the scripts of the opening share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors); let form = &s.get(id).unwrap().form; @@ -5891,9 +5970,10 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { let expect = (rows * (rows + 1)).to_string(); assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::>()); eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections); - // A change recalculates them all again, well within the event budget. + // A change recalculates them all again, in one more engine. let started = std::time::Instant::now(); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 2, "the scripts of the change share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]); @@ -5901,6 +5981,28 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { eprintln!("recalculated after a change in {:?}", started.elapsed()); } +/// The time the scripts of an opening share stops the calculations it has no time for, and +/// says how many were skipped; a change's scripts are stopped the same way. +#[test] +fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { + let (sections, rows) = (10, 20); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); + // A time scale of 0 leaves no time at all: every script is skipped before it starts. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(0); + let engines = pdfcraft_js::xfa_session::engines_started(); + let id = s.open("many.pdf", None, bytes, None).expect("opens"); + let skipped = sections * rows + sections; + let stopped = format!("The form's scripts were stopped: the scripts took more than 0 seconds; {skipped} more were skipped"); + assert_eq!(s.take_js_output(id).errors, vec![stopped.clone()]); + let totals = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("total")).map(|f| f.value.clone()).collect::>(); + assert_eq!(totals(&s).len(), sections * rows); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert!(s.take_js_output(id).errors.iter().any(|e| e.starts_with("The form's scripts were stopped: the scripts took more than 0 seconds"))); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); +} + /// The scripts of one event share the form as the engine holds it: a failed script's /// half-done writes reach nobody, and a script reads the saved value a choice list was given /// by its shown text. @@ -8550,3 +8652,70 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); bytes } + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d533faf7e0..290231cf73 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -89,4 +89,17 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, + /// so the page under its first place doesn't come along. Read-only, like + /// [`Self::added_content_preview`]. + pub fn added_content_alone(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::isolate_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: RenderConfig { transparent: true, ..self.config.clone() } }) + })? + } } diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 2f98efdb04..dd011d47fc 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -128,6 +128,10 @@ pub(crate) struct XfaRun<'a> { pub datasets: Option, /// A script ran past its time limit and was abandoned: the form's scripts should stay off. pub ran_away: bool, + /// What every time limit of the scripts is multiplied by (the event's budget, the engine's + /// start and each script's own): 1, unless the session was given another + /// ([`crate::Session::with_xfa_time_scale`]). + pub time_scale: u32, } /// The live form node `n`, with its descendants, as the script engine's snapshot of it. @@ -277,6 +281,7 @@ impl<'a, 'b> Runner<'a, 'b> { fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self { let data = pdfcraft_xfa::data_of(doc); let ov = pdfcraft_xfa::overrides(doc); + let budget_ms = budget_ms.saturating_mul(u64::from(run.time_scale)); Runner { doc, tpl, @@ -311,7 +316,8 @@ impl<'a, 'b> Runner<'a, 'b> { /// The script engine for this event. fn session(&mut self) -> &mut XfaSession { let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; - self.session.get_or_insert_with(|| XfaSession::new(doc_info)) + let scale = self.run.time_scale; + self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale)) } /// Give the object at `som` the value `text` in the data and in the live form (refreshed @@ -661,7 +667,7 @@ impl<'a, 'b> Runner<'a, 'b> { event, root, limits: limits_for(&ev.activity), - timeout: timeout_for(&ev.activity), + timeout: timeout_for(&ev.activity).saturating_mul(self.run.time_scale), }; let o = self.session().run(script); if o.abandoned { @@ -893,7 +899,7 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s r.run(&ev, "")?; } // The calculations that follow get a budget of their own. - r.clock = Clock::start(OPEN_BUDGET_MS); + r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale))); r.calculates()?; r.finish().map(|_| ()) } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1303254d66..a9c43fb423 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,7 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text. Word keeps pages with +//! document: headings are the paragraphs set larger than the body text, and a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph. +//! Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -30,6 +32,10 @@ pub struct Block { pub color: [f64; 3], /// Counterclockwise baseline angle in user space (degrees). pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], } /// An image of a page. @@ -81,7 +87,8 @@ pub struct Table { /// What goes into the output, in order. enum Item<'a> { - Para(&'a Block, u8), + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), /// Owned: tables are detected per page inside [`items`], so they cannot borrow. Table(Table), Img(&'a Image), @@ -149,6 +156,12 @@ const CONTINUATION_PROBE: usize = 64; /// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running /// on (a link inside a sentence, a superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); blocks @@ -162,11 +175,88 @@ fn continuations(blocks: &[Block]) -> Vec { .iter() .take_while(|(end, _)| *end <= b.rect[0] + 1.0) .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) - .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) }) .collect() } + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -374,12 +464,14 @@ fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - let mut parts: Vec<(f64, f64, Item)> = p - .blocks - .iter() - .enumerate() - .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) - .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) .collect(); parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); @@ -434,15 +526,26 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi ); for it in items(pages) { match it { - Item::Para(b, lvl) => { - let mut t = esc(&b.text); - if b.italic { - t = format!("{t}"); + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); } - if b.bold && lvl == 0 { - t = format!("{t}"); - } - let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); match lvl { 0 => s.push_str(&format!("{t}

\n")), l => s.push_str(&format!("{t}\n")), @@ -711,7 +814,6 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. let mut after_table = false; - let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); let mut shape_id = 0usize; for (page_index, page) in pages.iter().enumerate() { let (pw, _) = word_page_size(page); @@ -722,13 +824,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } else { for it in items(std::slice::from_ref(page)) { match &it { - Item::Para(b, lvl) => { + Item::Para(runs, lvl) => { let style = match lvl { 1 => "", 2 => "", _ => "", }; - body.push_str(&format!("{style}{}", run(b))); + body.push_str(""); + body.push_str(style); + for r in runs { + body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + body.push_str(""); } Item::Table(t) => { if after_table { @@ -873,7 +980,7 @@ pub fn rtf(pages: &[Page]) -> String { }; for it in &items { match it { - Item::Para(b, _) => note(b.color), + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), Item::Img(_) | Item::PageBreak => {} } @@ -894,16 +1001,32 @@ pub fn rtf(pages: &[Page]) -> String { } for it in items { match it { - Item::Para(b, _) => { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); } - if b.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(b.color)); - s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); + s.push_str("\\par}\n"); } Item::Table(t) => { let mut edges = t.cols.clone(); @@ -957,6 +1080,7 @@ mod tests { italic: false, color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], rotation: 0.0, + space_around: [false; 2], }) .collect(); Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } @@ -995,6 +1119,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; Page { width: 612.0, @@ -1098,6 +1223,7 @@ mod tests { italic: false, color: [0.8; 3], rotation: 0.0, + space_around: [false; 2], }); p.images = page().images; let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); @@ -1184,6 +1310,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }); let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); @@ -1237,6 +1364,7 @@ mod tests { italic: true, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }); let r = rtf(&[p]); assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); @@ -1254,6 +1382,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; Page { width: 612.0, @@ -1277,6 +1406,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, ], images: Vec::new(), @@ -1329,7 +1459,16 @@ mod tests { .map(|i| { let (row, col) = (i / 2, i / 2 + i % 2); let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + Block { + text: "x".into(), + rect: [x, y, x + 8.0, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } }) .collect(); let started = std::time::Instant::now(); @@ -1356,6 +1495,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], } }) }) @@ -1370,7 +1510,16 @@ mod tests { } fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { - Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + Block { + text: t.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } } /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks @@ -1400,6 +1549,8 @@ mod tests { }; let h = html(std::slice::from_ref(&p), "Regulations"); assert!(!h.contains(""), "running text became a table: {h}"); + // The link is a run of its sentence, in its own colour (#4158). + assert!(h.contains("

6.3. 2. All content shown at http"), "{h}"); for b in &p.blocks { assert!(h.contains(&b.text), "{} was lost: {h}", b.text); } @@ -1487,6 +1638,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let p = Page { width: 612.0, @@ -1541,4 +1693,158 @@ mod tests { let rels = part(&zip, "word/_rels/document.xml.rels"); assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA); } + + /// The flowing paragraphs of a Word document (table cells left out), each as its runs' text + /// and whether the run is italic. + fn word_paragraphs(xml: &str) -> Vec> { + let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::(); + body.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| { + let text = r.split_once("")?.1.split_once("")?.0; + Some((text.to_string(), r.contains(""))) + }) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect() + } + + fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> { + r.iter().map(|(t, i)| (t.to_string(), *i)).collect() + } + + /// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman + /// text after it, the way reading blocks split a sentence at its style changes (#4158). The + /// italic face stands a little taller than the roman one around it. + fn italic_sentence() -> Vec { + vec![ + block("Before", 72.0, 700.0, 110.0, 712.0), + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) }, + block("after.", 161.0, 700.0, 190.0, 712.0), + ] + } + + /// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a + /// run in its own style, in every Office format. + #[test] + fn an_italic_word_stays_in_its_sentence() { + let mut blocks = italic_sentence(); + blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0)); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])], + "{xml}" + ); + let h = html(std::slice::from_ref(&p), "Opinion"); + assert!(h.contains("

Before Chevron after.

"), "{h}"); + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}"); + // The longest line is the slide's title; the sentence is one paragraph of the body. + let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml"); + assert!( + pxml.contains("Before Chevron after."), + "{pxml}" + ); + } + + /// #4158: the runs keep their order along the line whatever order the blocks come in, and the + /// paragraph keeps its place on the page, though the italic run stands taller than the first. + #[test] + fn a_sentence_keeps_its_reading_order() { + let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)]; + blocks.extend(italic_sentence().into_iter().rev()); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])], + "{xml}" + ); + } + + /// The text of a block leaves out the spaces at its ends, and the box of a run often takes in + /// its trailing space, so runs can touch. The source's own space still separates them; runs + /// that touch with no space between them (a superscript) stay joined. + #[test] + fn runs_keep_the_spaces_the_source_had_between_them() { + let blocks = vec![ + Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) }, + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) }, + Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) }, + block("E = mc", 72.0, 660.0, 110.0, 672.0), + Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])], + "{xml}" + ); + } + /// Text turned another way (a label up the margin) is not a run of the line it touches. + #[test] + fn turned_text_is_not_a_run_of_the_line_beside_it() { + let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Turned"); + assert!(h.contains("

Before

") && h.contains("

SIDE

"), "{h}"); + } + /// A block that starts where the line above ends is the next line, not a run of it. + #[test] + fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); + } + + /// A superscript beside a table cell stays in the table: table detection consumes it before + /// paragraphs are formed, and a sentence after the table still keeps its italic run. + #[test] + fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); + } + + /// A bold lead-in followed by body text on its line is body text, not a heading; a heading + /// with a smaller footnote marker after it stays a heading. + #[test] + fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); + } } diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 886a4bdbde..2edc0e25c9 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -1,11 +1,12 @@ //! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page, //! largest text as slide title, remaining paragraphs as body text, images and tables between them. -use crate::{Block, Image, Page, Table, Zip, esc, hex}; +use crate::{Image, Page, Run, Table, Zip, esc, hex}; /// Items on a slide in reading order. enum SlideBodyItem<'a> { - Para(&'a Block), + /// A paragraph's runs (at least one). + Para(Vec>), Table(Table), Img(&'a Image), } @@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .map(|(i, _)| i); let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); - for (bi, b) in p.blocks.iter().enumerate() { - if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { - continue; - } - items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + // The title is a shape of its own, so it joins no paragraph. + let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); + for runs in crate::paragraphs(&p.blocks, &skip) { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } for t in tables { items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); @@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { - let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); - paras_xml.push_str(&format!("{run}")); - let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); - est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + paras_xml.push_str(""); + for r in runs { + paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + paras_xml.push_str(""); + let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add); + let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max); + let lines = (len as f64 / 60.0).ceil().max(1.0); + est_h_pt += (size * 1.3) * lines + 6.0; i += 1; } let box_h = height_emu(est_h_pt); @@ -354,6 +360,7 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" #[cfg(test)] mod tests { use super::*; + use crate::Block; use crate::tests::part; fn sample_page() -> Page { @@ -369,6 +376,7 @@ mod tests { italic: false, color: [0.1, 0.2, 0.3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Revenue grew 25% year over year across all regions.".into(), @@ -378,6 +386,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Operational expenses remained flat.".into(), @@ -387,6 +396,7 @@ mod tests { italic: true, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, ], images: vec![Image { @@ -510,6 +520,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let table_page = Page { width: 612.0, @@ -611,6 +622,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let mut blocks = vec![huge("Title", f64::MAX, 500.0)]; for k in 0..40 { @@ -682,6 +694,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let p = Page { width: 720.0, @@ -696,6 +709,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, // Table where one cell has a very large number (e.g. 36 pt callout in the table) cell("Metric", 72.0, 400.0, 100.0, 12.0), diff --git a/crates/forms/README.md b/crates/forms/README.md index 4aec223ee9..93b65bd14e 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +// Many values: as set_value one by one, but reading the form once and recalculating once. +let results: Vec> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?; reset(&mut doc, None)?; // Clear form (back to /DV) // Prepare a form @@ -58,7 +60,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in one field redraws that field, not the form. Check boxes and radio buttons with no appearance for their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats - symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. + symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`, + over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and + their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 7b706e9ae9..3119481cff 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -844,7 +844,10 @@ fn group(int: &str, sep: char) -> String { fn number_text(v: f64, decimals: u8, sep: u8) -> String { // Round half away from zero, as JavaScript's toFixed does for these values. let p = 10f64.powi(i32::from(decimals)); - let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); let (g, d) = match sep { 1 => (None, '.'), @@ -2060,4 +2063,12 @@ mod undelimited_tests { assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); } + + #[test] + fn huge_finite_numbers_do_not_print_inf() { + let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307"); + assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}"); + let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306"); + assert!(!p.contains("inf"), "got {p}"); + } } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index da25c211c0..c1bb5630a6 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -480,7 +480,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { /// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button /// (`/PCXfaRound true`, written by the XFA layout). -fn is_round(doc: &Document, wd: &Dict) -> bool { +pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool { matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) } @@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: } /// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when -/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and -/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes -/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// the file has no appearance for that state, or leaves its appearances to the viewer +/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption +/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may +/// use too) as a ZapfDingbats character, as in the check box example of /// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is /// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most /// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 81e7ec9dc4..eccd9a34e2 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -963,6 +963,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel rotation: 0, caption: None, list: None, + round: true, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 9fd64d1195..1559e5497e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -118,6 +118,9 @@ pub struct Widget { pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, + /// Drawn round: a radio button, or a check box laid out for a round XFA check button + /// (`/PCXfaRound true`). + pub round: bool, } /// A terminal form field. @@ -570,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields { Fields { items, adopted_page_fields } } +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); @@ -882,6 +899,7 @@ fn walk( }) .flatten(), list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), + round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), }) }) .collect(); @@ -931,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script if all.is_empty() { return Err(FormError::NoForm); } - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; if f.read_only() { return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, &all)?; + author::form_fonts_as_objects(doc, all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); if !twins.is_empty() { let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); for t in twins { share_value(doc, f.obj, t, &taken, scripts)?; } } - recalculate_with(doc, scripts)?; Ok(()) } @@ -1465,8 +1553,9 @@ pub fn need_appearances(doc: &Document) -> bool { /// writer left the appearances to the viewer), and otherwise each widget without a normal /// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons /// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not -/// changed: saving writes the file as it was. +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is +/// not changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1484,19 +1573,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance .and_then(|d| d.get(b"AP")) .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else a check box's value, else Off. An Off state may go without one (it shows - // nothing); an on state needs one. + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. let state = match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) } _ => None, }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); let missing = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, FieldKind::PushButton => normal.is_none(), FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, (None, _) => true, (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), _ => false, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3e919ec7c5..f52be5306d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4360,6 +4360,224 @@ fn comb_fields_show_their_cells() { assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } +/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that +/// sets `/NeedAppearances` (`need`) or not. +fn mk_buttons(need: bool) -> Document { + let flag = if need { "/NeedAppearances true " } else { "" }; + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + format!( + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>" + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6 + widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))), + widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))), + widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))), + widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10 + widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))), + widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))), + widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))), + widget( + "odd", + &format!( + "/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}", + states("Yes") + ), + ), + "<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + ]) +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it, +/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour +/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched. +#[test] +fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() { + let doc = mk_buttons(true); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, state, symbol, colour) in [ + ("check", "Yes", "(4) Tj", "0 g"), + ("cross", "Yes", "(8) Tj", "1 0 0 rg"), + ("diamond", "Yes", "(u) Tj", "0 g"), + ("square", "Yes", "(n) Tj", "0 g"), + ("star", "Yes", "(H) Tj", "0 g"), + ("radio", "B", "(l) Tj", "0 g"), + ] { + let shown = state_ap(©, &field(&all, name).widgets[0], state); + assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}"); + assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}"); + } + let check = state_ap(©, &field(&all, "check").widgets[0], "Yes"); + assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}"); + assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border"); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}"); + // Nonsense in /MK, /DA and /BS: the default check, no border, and no panic. + let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes"); + assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}"); + let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look"); + assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched"); +} + +/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown +/// as they are, `/MK` or not: no copy is needed. +#[test] +fn without_need_appearances_buttons_keep_their_own_appearances() { + assert!(with_field_appearances(&mk_buttons(false)).is_none()); +} + +/// A form for filling many values at once: two summed fields and their calculated total, a +/// name two field dictionaries share, a check box, a radio group, a list and a read-only field. +fn batch_form() -> Document { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>" + .into(), + tx("a", 700, "/V (1)"), + tx("b", 660, ""), + tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#), + tx("dup", 580, ""), + tx("dup", 540, ""), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(), + "<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + tx("locked", 500, "/Ff 1 /V (kept)"), + ]; + document(&objs) +} + +/// What a filled form shows: every field as read, and each widget's drawn appearance. +fn filled(doc: &Document) -> (Vec, Vec) { + let all = fields(doc); + let looks = all + .iter() + .flat_map(|f| &f.widgets) + .map(|w| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())); + match n.map(|n| doc.resolve(&n)) { + Some(n) => match &*n { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(), + other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(), + }, + None => String::new(), + } + }) + .collect(); + (all, looks) +} + +/// Filling values in one batch leaves the form as filling them one by one does: the values, +/// the twin of a shared name, the calculated total and every widget's appearance; a name given +/// twice ends with its last value, and is checked against the value it took earlier in the batch; +/// and each value fails as it would on its own. +#[test] +fn set_values_fills_as_set_value_does_one_by_one() { + let values: Vec<(String, FieldValue)> = vec![ + ("a".into(), FieldValue::Text("2".into())), + ("b".into(), FieldValue::Text("3".into())), + ("dup".into(), FieldValue::Text("shared".into())), + ("agree".into(), FieldValue::Check(true)), + ("size".into(), FieldValue::Radio(Some("L".into()))), + ("country".into(), FieldValue::Choice(vec!["fr".into()])), + ("nowhere".into(), FieldValue::Text("x".into())), + ("locked".into(), FieldValue::Text("changed".into())), + ("size".into(), FieldValue::Radio(Some("XL".into()))), + ("dup".into(), FieldValue::Text("again".into())), + ("a".into(), FieldValue::Text("10".into())), + // Refused only because the batch already selected L: the group keeps one selected. + ("size".into(), FieldValue::Radio(None)), + ]; + let mut one_by_one = batch_form(); + let expected: Vec> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect(); + let mut batch = batch_form(); + let results = set_values(&mut batch, &values).expect("recalculates"); + assert_eq!(results, expected); + assert_eq!( + results.iter().filter(|r| r.is_err()).count(), + 4, + "the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}" + ); + let (all, looks) = filled(&batch); + assert_eq!((all.clone(), looks), filled(&one_by_one)); + assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value"); + assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value"); + // And as saved. + assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one))); +} + +/// A form without fields refuses each value, and an empty batch changes nothing. +#[test] +fn set_values_without_a_form_or_values() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoForm)]); + let mut form = batch_form(); + assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new()); + assert!(!form.is_modified()); + // Nothing filled, nothing recalculated: the total stays as the file has it. + let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]); + assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified()); +} + +/// Filling many fields reads the form a fixed number of times, not once or twice per field +/// (which took minutes to open a form with thousands of fields whose XFA data was filled in +/// elsewhere). +#[test] +fn set_values_reads_the_form_once_not_per_value() { + let n: u32 = 60; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10)); + } + let mut doc = document(&objs); + let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect(); + let before = form_reads(); + let results = set_values(&mut doc, &values).unwrap(); + let reads = form_reads() - before; + assert!(results.iter().all(Result::is_ok)); + // One reading to fill, one to recalculate. + assert!(reads <= 2, "{reads} readings of the form for {n} values"); + assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])])); + // A name filled again is read again, once: f0 and f1 again cost one more reading. + let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect(); + let before = form_reads(); + assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); + let reads = form_reads() - before; + assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); +} + /// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and /// the group's buttons in the calculation order; and a text field `name` with two widgets. fn radio_group_form() -> Document { diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 7b0d0e88a7..39ecdbde5a 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -2115,7 +2115,8 @@ impl Interp<'_> { let a = self.arg_num(args, 0)?; let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; let k = 10f64.powi(places); - Val::Num((a * k).round() / k) + let scaled = a * k; + if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } } "count" => { // Non-null values and objects: an empty field doesn't count, a subform does. @@ -2278,8 +2279,11 @@ impl Interp<'_> { let mut i = 0; while i < b.len() { if b[i] == b'%' - && let Some(h) = - b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) + && let Some(h) = b + .get(i + 1..i + 3) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(h); i += 3; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 3d3c4ab524..9e96dcd2e3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1009,9 +1009,17 @@ mod formcalc { assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); } + #[test] + fn round_keeps_huge_values_finite() { + assert_eq!(calc("Round(1e300, 12) == 1e300"), "1"); + assert_eq!(calc("Round(1.2345, 2)"), "1.23"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); + assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); + assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); assert_eq!(calc("Ceil(2.1)"), "3"); assert_eq!(calc("Floor(-2.1)"), "-3"); assert_eq!(calc("Mod(7, 3)"), "1"); diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 91447454dd..28ce53994d 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -18,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome}; #[cfg(test)] pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +std::thread_local! { + static ENGINES_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many script engine threads the sessions made on this thread have started, for +/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of +/// calculations starts one. Per thread, so a caller counting its own events is not disturbed +/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread. +pub fn engines_started() -> u64 { + ENGINES_STARTED.with(std::cell::Cell::get) +} + /// What one script run needs. #[derive(Clone, Debug)] pub struct Script { @@ -58,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] mod native { use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; - use std::time::Instant; + use std::time::{Duration, Instant}; use super::{Script, run_script}; use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome}; @@ -83,11 +95,20 @@ mod native { worker: Option, /// A script ran past its limit: its thread is left to it and nothing more runs. dead: Option, + /// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default). + start_limit: Duration, } impl XfaSession { pub fn new(doc: XfaDoc) -> XfaSession { - XfaSession { doc, worker: None, dead: None } + XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT } + } + + /// Multiply the time the engine has to be ready for a script by `factor` (a script's + /// own time is [`Script::timeout`]). + pub fn with_time_scale(mut self, factor: u32) -> XfaSession { + self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor); + self } /// Why nothing more runs, once a script was abandoned. @@ -115,11 +136,12 @@ mod native { } }); spawned.map_err(|e| format!("the script engine could not start: {e}"))?; - match from.recv_timeout(ENGINE_START_LIMIT) { + super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1))); + match from.recv_timeout(self.start_limit) { Ok(Reply::Ready) => {} Ok(_) => return Err("the script engine answered out of turn".into()), Err(RecvTimeoutError::Timeout) => { - return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())); + return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64())); } Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()), } @@ -129,8 +151,9 @@ mod native { } /// Run one script, waiting at most its `timeout` from when the engine is ready for it - /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is - /// abandoned on its thread and the session is dead. + /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless + /// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread + /// and the session is dead. pub fn run(&mut self, script: Script) -> XfaOutcome { let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; @@ -138,6 +161,7 @@ mod native { return abandoned(why.clone()); } let timeout = script.timeout; + let start_limit = self.start_limit; let worker = match self.worker() { Ok(w) => w, Err(why) => return failed(why), @@ -148,7 +172,7 @@ mod native { return failed("the script engine stopped with an internal error".into()); } // The engine's start, then the script's own time. - let (mut running, mut limit) = (false, ENGINE_START_LIMIT); + let (mut running, mut limit) = (false, start_limit); let mut deadline = Instant::now().checked_add(limit); loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); @@ -167,7 +191,7 @@ mod native { let why = if running { format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()) } else { - format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()) + format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64()) }; self.dead = Some(why.clone()); // Dropping the sender ends the thread's loop once the script is done. @@ -199,6 +223,11 @@ mod native { XfaSession { doc } } + /// No engine to start on wasm: nothing to scale. + pub fn with_time_scale(self, _factor: u32) -> XfaSession { + self + } + pub fn dead(&self) -> Option<&str> { None } diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index cc1bbb0884..2bb4122d2f 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -265,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8 } } +/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a +/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests` +/// tree, then the catalog `/Dests`. `None` when a name names nothing. +pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option> { + // Names only: the page map is not needed to look one up. + let mut targets = Targets { pages: HashMap::new(), names: None }; + explicit_dest(doc, &mut targets, dest, 0) +} + /// The page index an explicit destination array names. fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option { match dest.first()? { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1ba8861cbe..1fd60d744a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2460,10 +2460,89 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() { assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() }); // A changed magnification writes a destination of its own. let mut doc = doc; - crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap(); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap(); let catalog = doc.get(root).as_dict().cloned().unwrap(); assert_ne!(catalog.get(b"OpenAction"), Some(&action)); - assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth); + } +} + +/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at +/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. +fn doc_opening_at(action: Object) -> Document { + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0)); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)])); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])])); + let mut names = pdfcraft_cos::Dict::new(); + names.set(b"Dests".to_vec(), Object::Dict(tree)); + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action); + c.set(b"Dests".to_vec(), Object::Dict(dests)); + c.set(b"Names".to_vec(), Object::Dict(names)); + }) + .unwrap(); + doc +} + +fn open_action(doc: &Document) -> Option { + doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned()) +} + +/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing +/// page 1 in Document Properties on a document that opens at a named destination elsewhere +/// writes a destination to page 1; it used to read the name as page 1 and change nothing. +#[test] +fn choosing_page_one_replaces_a_named_opening_destination() { + use crate::view::Magnification; + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let mut goto = pdfcraft_cos::Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), Object::name("there")); + for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] { + let mut doc = doc_opening_at(action.clone()); + // The dialog shows where the document really opens. + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}"); + // Page 1 at the same magnification: an explicit destination to page 1. + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap(); + let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]); + assert_eq!(open_action(&doc), Some(written), "{action:?}"); + let reopened = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}"); + // Page 1 at the default magnification: no opening action, so the first page opens. + let mut doc = doc_opening_at(action.clone()); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap(); + assert_eq!(open_action(&doc), None, "{action:?}"); + assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}"); + } +} + +/// A named opening destination that already goes to the chosen page and magnification is kept, +/// and so is one that names nothing (it reads as page 1) until another page is chosen. +#[test] +fn a_named_opening_destination_is_kept_while_it_still_holds() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + for (action, magnification) in + [(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)] + { + let mut doc = doc_opening_at(action.clone()); + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap(); + let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" }; + assert_eq!( + open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())), + Some((Some(page2.clone()), Some(Object::name(kind)))), + "{action:?}" + ); } } diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index b51df1aa55..899293b980 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -118,10 +118,12 @@ pub fn initial_view(doc: &Document) -> InitialView { }; } } - // The opening destination: an explicit array, or a GoTo action's. + // The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a + // name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification + // read here are where the document opens. A name that names nothing reads as page 1. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { - Object::Array(a) => Some(a.clone()), - Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()), + Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o), + Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)), _ => None, }); if let Some(d) = dest { @@ -195,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } else { vp.remove(b"Direction"); } + // A destination, explicit or named (§12.3.2.4), or a GoTo action to one. let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) { None => true, - Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")), + Some(o) => { + matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")) + } }; let dest = match v.magnification { Magnification::Default if v.page == 0 => None, diff --git a/crates/platform/README.md b/crates/platform/README.md index ea7af7a079..fea358e508 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. -Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index c0e8f990bc..092aa65239 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -34,7 +34,8 @@ pub fn create_staging( /// [`create_staging`], giving the new file the mode and group of the file `keep` describes on /// Unix. It is created readable and writable by its owner only and then given them, so its mode /// is never more open than that of the file it will replace, not even while it is still empty (a -/// handle opened then would keep reading what is written later). The owner is the saver's. +/// handle opened then would keep reading what is written later). The owner is the saver's. On +/// Windows it is created with that file's [`KEPT_ATTRIBUTES`]. fn create_staging_as( dir: &Path, name: &str, @@ -63,8 +64,15 @@ fn create_staging_as( use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } + // Given as the file is created, so a hidden target's staging file is never visible. + #[cfg(windows)] + if let Some(keep) = keep { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES); + } match options.open(&tmp) { Ok(file) => { + #[cfg(unix)] if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. @@ -78,9 +86,18 @@ fn create_staging_as( Err(e) => return Err(e), } } + #[cfg(not(any(unix, windows)))] + let _ = keep; Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The Windows attributes a replaced file passes on to its staging file (#4593): +/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the +/// file. A read-only file is refused before anything is staged, and the archive bit is the +/// system's to set on a changed file; the other attributes describe how the volume stores it. +#[cfg(windows)] +const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4; + /// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits /// are meant for that file's group, so the staging file joins it first. Where it can't (the saver /// isn't a member), group and others get nothing: someone the group bits shut out would @@ -96,11 +113,6 @@ fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs:: mode } -#[cfg(not(unix))] -fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { - keep.permissions() -} - /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -119,7 +131,8 @@ pub fn staging_suffixes() -> impl Iterator { /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file /// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the -/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where +/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and +/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. /// On macOS, accessible extended attributes of an existing regular destination are copied /// before flushing; a copy failure refuses the save rather than losing metadata (#2658). @@ -146,10 +159,14 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and + // system attributes on Windows (#4593). A save replaces a symbolic link, not the file it + // points to, so on Windows the attributes are the link's own. #[cfg(unix)] let keep = std::fs::metadata(path).ok(); - #[cfg(not(unix))] + #[cfg(windows)] + let keep = std::fs::symlink_metadata(path).ok(); + #[cfg(not(any(unix, windows)))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). #[cfg(any(windows, target_os = "macos"))] @@ -661,4 +678,69 @@ mod tests { assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); let _ = std::fs::remove_dir_all(&dir); } + + /// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and + /// its staging file already is while it is written; a plain file stays plain, though the + /// staging name starts with a dot; a new file gets no attribute from anywhere. + #[cfg(windows)] + #[test] + fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A save replaces a symbolic link, not the file it points to, so the saved file takes the + /// link's own attributes: a plain link to a hidden file saves as a plain file, and the file + /// it pointed to is left as it was. + #[cfg(windows)] + #[test] + fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/render/tests/it/luminosity_group_space.rs b/crates/render/tests/it/luminosity_group_space.rs new file mode 100644 index 0000000000..d4f54c1a77 --- /dev/null +++ b/crates/render/tests/it/luminosity_group_space.rs @@ -0,0 +1,132 @@ +//! A luminosity soft mask takes its values from the gray of its group's colour (ISO 32000-2 +//! §11.5.3). In a `/DeviceCMYK` group that gray is `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` +//! (§10.4.2.3), so 100% K masks fully, whatever profile CMYK is displayed through. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +/// The page's grey backdrop, `0.5 g`, as the renderer writes it. +const BACKDROP: f64 = 128.0; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, content: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +fn render(bytes: Vec) -> (Vec, u32) { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + (page.rgba.to_vec(), page.width) +} + +fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 3] { + let offset = usize::try_from((y * width + x) * 4).unwrap(); + [rgba[offset], rgba[offset + 1], rgba[offset + 2]] +} + +/// Red filled over the grey backdrop under a luminosity mask whose group, in `space`, paints its +/// left half with `paint`; its right half is the backdrop `/BC` (`bc`, when given). Returns the +/// mask values read back from the left and the right half. +fn mask_values(space: &str, paint: &str, bc: Option<&str>) -> (f64, f64) { + let bc = bc.map(|bc| format!("/BC {bc}")).unwrap_or_default(); + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >>".to_string(), + stream("", "0.5 g 0 0 100 100 re f /M gs 1 0 0 rg 0 0 100 100 re f"), + format!("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R {bc} >> >>"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS {space} >>"), + &format!("{paint} 0 0 50 100 re f"), + ), + ]; + let (rgba, width) = render(pdf(&objects)); + let value = |x| { + let [r, g, b] = pixel(&rgba, width, x, 50); + // Red over grey: red rises from the backdrop to 255 and green and blue fall to 0 as the + // mask goes from 0 to 1. + let from_red = (f64::from(r) - BACKDROP) / (255.0 - BACKDROP); + let from_green = (BACKDROP - f64::from(g)) / BACKDROP; + let from_blue = (BACKDROP - f64::from(b)) / BACKDROP; + assert!( + (from_red - from_green).abs() < 0.03 && (from_green - from_blue).abs() < 0.03, + "{space} {paint}: pixel {:?} is not red over the backdrop", + [r, g, b] + ); + from_green + }; + (value(25), value(75)) +} + +fn assert_mask(space: &str, paint: &str, bc: Option<&str>, left: f64, right: f64) { + let (got_left, got_right) = mask_values(space, paint, bc); + assert!((got_left - left).abs() < 0.02, "{space} group, `{paint}`: mask {got_left:.3}, expected {left}"); + assert!((got_right - right).abs() < 0.02, "{space} group, /BC {bc:?}: mask {got_right:.3}, expected {right}"); +} + +#[test] +fn black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully() { + assert_mask("/DeviceCMYK", "0 0 0 1 k", None, 0.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 0 k", None, 1.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 0.5 k", None, 0.5, 0.0); +} + +#[test] +fn each_process_ink_in_a_device_cmyk_group_masks_by_its_weight() { + assert_mask("/DeviceCMYK", "1 0 0 0 k", None, 0.7, 0.0); + assert_mask("/DeviceCMYK", "0 1 0 0 k", None, 0.41, 0.0); + assert_mask("/DeviceCMYK", "0 0 1 0 k", None, 0.89, 0.0); + assert_mask("/DeviceCMYK", "1 1 1 1 k", None, 0.0, 0.0); + // The stroking colour too, and a colour selected with `cs`/`sc`. + assert_mask("/DeviceCMYK", "0 0 0 1 K 0 0 0 1 k 50 w 25 0 m 25 100 l S", None, 0.0, 0.0); + assert_mask("/DeviceCMYK", "/DeviceCMYK cs 0 0 0 0.5 sc", None, 0.5, 0.0); +} + +#[test] +fn a_device_cmyk_backdrop_colour_masks_by_its_gray() { + assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[0 0 0 0]"), 0.0, 1.0); + assert_mask("/DeviceCMYK", "0 0 0 0 k", Some("[0 0 0 1]"), 1.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[1 0 0 0]"), 0.0, 0.7); +} + +#[test] +fn gray_and_rgb_groups_mask_as_before() { + assert_mask("/DeviceGray", "0 g", None, 0.0, 0.0); + assert_mask("/DeviceGray", "1 g", Some("[1]"), 1.0, 1.0); + assert_mask("/DeviceGray", "0.5 g", None, 0.5, 0.0); + assert_mask("/DeviceRGB", "0 0 0 rg", None, 0.0, 0.0); + assert_mask("/DeviceRGB", "1 1 1 rg", Some("[1 1 1]"), 1.0, 1.0); + assert_mask("/DeviceRGB", "0.5 0.5 0.5 rg", None, 0.5, 0.0); +} + +#[test] +fn device_cmyk_on_the_page_keeps_its_display_colour() { + // Only a luminosity mask in a DeviceCMYK group reads CMYK as its gray. On the page, CMYK is + // displayed through the CMYK profile as before, where 100% K is a dark grey. + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(), + stream("", "0 0 0 1 k 0 0 100 100 re f"), + ]; + let (rgba, width) = render(pdf(&objects)); + let [r, g, b] = pixel(&rgba, width, 50, 50); + assert!(r > 10 && g > 10 && b > 10 && r < 80, "100% K on the page: {:?}", [r, g, b]); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index ca9adf016e..7c396b6f50 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -3,6 +3,7 @@ mod cid_fallback_query; mod inline_image_ei; mod jbig2_invariants; +mod luminosity_group_space; mod named_form_properties; mod search; mod separation_all; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c7015c4651..02bc000ed6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3413,8 +3413,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); if editing_content { - let page_tex = view.pages.get(&i).map(|p| p.tex.id()); - view.move_background.paint(painter, &xf, i, info, &view.content, page_tex, pointer); + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); } if view.show_page_gaps { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); @@ -3860,20 +3859,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons are round, and so is theirs (as in Acrobat). Only what can - // be filled is marked: a read-only widget (its field's, or a radio button's own) is - // left as it is drawn. + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. if view.highlight_fields { for f in form.iter() { let required = f.has(pdfcraft_engine::field_flags::REQUIRED); - let round = f.kind == pdfcraft_engine::FormFieldKind::Radio; for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { let r = w.rect; let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if round { + if w.round { let radius = sr.width().min(sr.height()) / 2.0; painter.circle_filled(sr.center(), radius, tint); painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color)); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index ed4d0622fb..0d86f7dd8f 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -118,6 +118,10 @@ pub(crate) struct MoveBackground { key: Option<(u64, usize, usize)>, renderer: Option, texture: Option, + /// The item alone on a transparent page: the pixels that follow the pointer. The page's own + /// raster has whatever the item covers in its box too, which must stay where it is (#4464). + item_renderer: Option, + item: Option, tag: Option, /// The item's display-space box when the gesture started. from: Option<[f64; 4]>, @@ -130,7 +134,12 @@ pub(crate) struct MoveBackground { impl MoveBackground { pub fn pending(&self) -> bool { - self.renderer.is_some() && self.texture.is_none() + self.renderer.is_some() && self.item_renderer.is_some() && (self.texture.is_none() || self.item.is_none()) + } + + /// Both rasters are in: the page without the item, and the item alone. + fn ready(&self) -> bool { + self.texture.is_some() && self.item.is_some() } pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { @@ -145,6 +154,7 @@ impl MoveBackground { *self = Self { key, ..Default::default() }; if let Some((_, page, index)) = key { self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.item_renderer = doc.added_content_alone(page, index).ok().map(|p| p.renderer()); self.from = on_page(doc.added(), page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); self.picture = picture(page, index); } @@ -152,39 +162,60 @@ impl MoveBackground { if self.settling.is_some() { return; } - let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let (Some((_, page, _)), Some(renderer), Some(item_renderer)) = (self.key, self.renderer.as_ref(), self.item_renderer.as_ref()) else { + return; + }; let Some(p) = doc.info.pages.get(page) else { return }; let max_side = ctx.input(|i| i.max_texture_side) as f32; let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); let tag = (scale * 1000.0) as u64; if self.tag != Some(tag) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + for r in [renderer, item_renderer] { + r.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + } self.tag = Some(tag); } - if let Some(out) = renderer.try_recv() { + if self.receive(ctx, tag) && self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Takes in the rasters rendered at `tag`. `false` when either failed: the preview is then + /// dropped whole, including rasters in from an earlier scale. + fn receive(&mut self, ctx: &egui::Context, tag: u64) -> bool { + let (Some(renderer), Some(item_renderer)) = (self.renderer.as_ref(), self.item_renderer.as_ref()) else { + return false; + }; + let mut failed = false; + for (r, slot, name) in + [(renderer, &mut self.texture, "added-content-move-background"), (item_renderer, &mut self.item, "added-content-move-item")] + { + let Some(out) = r.try_recv() else { continue }; if out.error.is_some() { - // Without a background the item stays put while dragged, as before. - self.renderer = None; - return; - } - if out.request.tag == tag { - self.texture = Some(ctx.load_texture( - "added-content-move-background", + failed = true; + } else if out.request.tag == tag { + *slot = Some(ctx.load_texture( + name, crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), egui::TextureOptions::LINEAR, )); } } - if self.pending() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + if failed { + // Without both rasters the item stays put while dragged, as before. + self.renderer = None; + self.item_renderer = None; + self.texture = None; + self.item = None; } + !failed } /// The move was applied: keep the preview, now at the new generation, until the page is /// rendered again. Any other edit ends it. pub fn committed(&mut self, edit: &Edit, generation: u64) { match edit { - Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + Edit::UpdateContent { page, index, content } if self.ready() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { self.key = Some((generation, *page, *index)); self.settling = Some(content.rect()); } @@ -198,21 +229,11 @@ impl MoveBackground { } } - /// The page without the moving item, then the item's pixels taken from `page_tex` (the page - /// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or - /// applied position. - #[allow(clippy::too_many_arguments)] - pub fn paint( - &self, - painter: &egui::Painter, - xf: &PageXform, - page: usize, - info: &DocInfo, - cv: &ContentView, - page_tex: Option, - pointer: Option, - ) { - let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + /// The page without the moving item, then the item's own pixels (from its raster alone, so + /// nothing it covered comes along) at the dragged (to the pages' `pointer`) or applied + /// position. + pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, pointer: Option) { + let (Some((_, kp, _)), Some(background), Some(item), Some(from)) = (self.key, &self.texture, &self.item, self.from) else { return }; if kp != page { return; } @@ -231,7 +252,7 @@ impl MoveBackground { xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); // A pixel around the box, so its edges come along. let (r, to) = (r.expand(1.0), to.expand(1.0)); - let mut mesh = egui::Mesh::with_texture(page_tex); + let mut mesh = egui::Mesh::with_texture(item.id()); for (a, b) in [ (r.left_top(), to.left_top()), (r.right_top(), to.right_top()), @@ -944,19 +965,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: } }); ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted)); - let mut crop = img.crop.map(|v| (v * 100.0).round()); + let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0; + // Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file. + let shown = img.crop.map(|v| v * 100.0); + let mut crop = shown; let mut changed = false; ui.horizontal(|ui| { for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() { ui.label(label); - // Applied when the drag ends, so a drag is one undo step. - let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%")); + // Up to what the opposite side leaves. Applied when the drag ends: one undo step. + let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0); + let Some(side) = crop.get_mut(k) else { continue }; + let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%")); changed |= r.drag_stopped() || (r.changed() && !r.dragged()); } }); if changed { let mut i = img.clone(); - i.crop = crop.map(|v| v / 100.0); + for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) { + if now != was { + *stored = now / 100.0; + } + } if i.crop != img.crop { out = Some(ImageAction::Update(AddedContent::Image(i))); } @@ -1024,6 +1054,35 @@ mod tests { assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom"); } + /// #4464: both rasters were in at one scale and a render at a new scale failed. The rasters + /// from the old scale must go too, or the item keeps following the pointer although the + /// preview gave up. + #[test] + fn failed_rerender_drops_the_rasters_from_the_earlier_scale() { + let ctx = egui::Context::default(); + let texture = |name| Some(ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), egui::TextureOptions::LINEAR)); + // Bytes no parser accepts: every render answers with an error. + let pool = || { + let pool = pdfcraft_render::RenderPool::new_inline(std::sync::Arc::new(b"not a pdf".to_vec()), Default::default()); + pool.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag: 2000, ..Default::default() }]); + pool + }; + let mut mb = MoveBackground { + key: Some((0, 0, 0)), + renderer: Some(pool()), + texture: texture("background"), + item_renderer: Some(pool()), + item: texture("item"), + tag: Some(2000), + from: Some([0.0, 0.0, 10.0, 10.0]), + ..Default::default() + }; + assert!(mb.ready(), "both rasters from the earlier scale are in"); + assert!(!mb.receive(&ctx, 2000), "the new scale's renders fail"); + assert!(mb.texture.is_none() && mb.item.is_none(), "no raster is left to draw the moving item from"); + assert!(!mb.ready() && !mb.pending()); + } + /// #4245: the page counts the spacing after a line's last character too, so a line that fits /// only without it wraps in the saved text and must wrap in the editor. #[test] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 25ddac1d5f..8b951d6f6d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2816,3 +2816,11 @@ certificate Version الإصدار # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today اليوم @plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools أدوات الصفحات + Insert pages from a file after the selection… إدراج صفحات من ملف بعد التحديد… + Rotate the selected pages counterclockwise تدوير الصفحات المحددة عكس اتجاه عقارب الساعة + Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة + Extract the selected pages… استخراج الصفحات المحددة… + Delete the selected pages حذف الصفحات المحددة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a8af8ed655..c3a692c495 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2503,3 +2503,11 @@ animation effects Off Изключени # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Днес @plural {n} reply|{n} replies {n} отговор|{n} отговора + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменти за страници + Insert pages from a file after the selection… Вмъкване на страници от файл след избраното… + Rotate the selected pages counterclockwise Завъртане на избраните страници обратно на часовниковата стрелка + Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка + Extract the selected pages… Извличане на избраните страници… + Delete the selected pages Изтриване на избраните страници diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 42b036d161..cc888dc973 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2801,3 +2801,11 @@ theme Appearance Aspecte Delete attachment Elimina l'adjunt Delete attachment {key} Elimina l'adjunt {key} @plural {n} reply|{n} replies {n} resposta|{n} respostes + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Eines de pàgina + Insert pages from a file after the selection… Insereix pàgines d'un fitxer després de la selecció… + Rotate the selected pages counterclockwise Gira les pàgines seleccionades en sentit antihorari + Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari + Extract the selected pages… Extreu les pàgines seleccionades… + Delete the selected pages Suprimeix les pàgines seleccionades diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 845f437845..db8fc1dab2 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2732,3 +2732,11 @@ certificate Version وەشان # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ @plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ئامرازەکانی پەڕە + Insert pages from a file after the selection… پەڕەکان لە فایلێکەوە لە دوای هەڵبژاردەکە دابنێ… + Rotate the selected pages counterclockwise پەڕە هەڵبژێردراوەکان پێچەوانەی میلی کاتژمێر بسوڕێنە + Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە + Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… + Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3cadb98e3b..28fb4e1d65 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2847,3 +2847,11 @@ certificate Version Verze # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dnes @plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Nástroje stránek + Insert pages from a file after the selection… Vložit stránky ze souboru za výběr… + Rotate the selected pages counterclockwise Otočit vybrané stránky proti směru hodinových ručiček + Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček + Extract the selected pages… Extrahovat vybrané stránky… + Delete the selected pages Odstranit vybrané stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b9dcc06f0..bc202ef1ab 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2541,3 +2541,11 @@ animation effects Off Aus # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Heute @plural {n} reply|{n} replies {n} Antwort|{n} Antworten + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Seitenwerkzeuge + Insert pages from a file after the selection… Seiten aus einer Datei nach der Auswahl einfügen… + Rotate the selected pages counterclockwise Ausgewählte Seiten gegen den Uhrzeigersinn drehen + Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen + Extract the selected pages… Ausgewählte Seiten extrahieren… + Delete the selected pages Ausgewählte Seiten löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dbe7969173..ebe7969656 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,3 +2546,11 @@ animation effects Off Ανενεργά # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Σήμερα @plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Εργαλεία σελίδων + Insert pages from a file after the selection… Εισαγωγή σελίδων από αρχείο μετά την επιλογή… + Rotate the selected pages counterclockwise Περιστροφή των επιλεγμένων σελίδων αριστερόστροφα + Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα + Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… + Delete the selected pages Διαγραφή των επιλεγμένων σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 951a3dd8c1..c839eb330a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2863,3 +2863,11 @@ theme Appearance Aspecto # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoy @plural {n} reply|{n} replies {n} respuesta|{n} respuestas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Herramientas de página + Insert pages from a file after the selection… Insertar páginas de un archivo después de la selección… + Rotate the selected pages counterclockwise Girar las páginas seleccionadas en sentido antihorario + Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario + Extract the selected pages… Extraer las páginas seleccionadas… + Delete the selected pages Eliminar las páginas seleccionadas diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv new file mode 100644 index 0000000000..297d3084b9 --- /dev/null +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -0,0 +1,2806 @@ +# Persian (fa) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Persian computing vocabulary. No proprietary localisation resource, string table or product UI +# (Acrobat's included) was consulted or copied; add entries the same way. +# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters. +# +# Right to left: like Arabic and Kurdish, `rtl: true` in i18n/mod.rs puts every entry into display +# order when the catalog loads ([`crate::bidi::display_rtl`]); entries are written here in logical +# (typing) order. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins +# compound words. + + Menu منو + File فایل + Edit ویرایش + Pages صفحه‌ها + View نمایش + Help راهنما + Preferences ترجیحات + Preferences… ترجیحات… + Interface language زبان رابط کاربری + Interface size اندازه رابط کاربری + Auto ({percent}%) خودکار ({percent}%) + Auto follows the system's text size. Pages always render at the display's full resolution. حالت خودکار از اندازه متن سیستم پیروی می‌کند. صفحه‌ها همیشه با وضوح کامل نمایشگر رندر می‌شوند. + Identity هویت + Name on new comments نام روی یادداشت‌های جدید + Hide hover previews for highlights without comment text پنهان کردن پیش‌نمایش شناور برای برجسته‌سازی‌های بدون متن یادداشت + Open… باز کردن… + Open Recent باز کردن موارد اخیر + Clear Recent Files پاک کردن فایل‌های اخیر + Remove from Recent حذف از موارد اخیر + Remove {name} from Recent حذف {name} از موارد اخیر + Pin folder to Home… سنجاق کردن پوشه به خانه… + Folders پوشه‌ها + Pin a folder… سنجاق کردن پوشه… + Pin a folder to Home سنجاق کردن پوشه به خانه + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. یک پوشه را سنجاق کنید تا جدید‌ترین PDFهای آن اینجا فهرست شوند، مثلاً جایی که اسکنر یا درایو ابری هم‌گام‌شده آن‌ها را ذخیره می‌کند. + Unpin برداشتن سنجاق + Unpin {folder} برداشتن سنجاق {folder} + Looking for PDFs… جستوجوی PDFها… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. این پوشه هم اکنون در دسترس نیست. ممکن است جابه جا شده باشد یا درایو یا هم‌گام‌سازی ابری آن آفلاین باشد. + No PDFs in this folder yet هنوز PDFی در این پوشه نیست + Show less نمایش کمتر + Show {n} more نمایش {n} مورد بیشتر + The newest {n} of {total} PDFs {n} PDF جدید‌تر از {total} PDF + Pinned folders need the desktop app پوشه‌های سنجاق‌شده به برنامه دسکتاپ نیاز دارند + Choose a folder on this computer to pin یک پوشه روی این رایانه برای سنجاق کردن انتخاب کنید + {folder} is already pinned to Home {folder} از قبل به خانه سنجاق شده است + You can pin up to {n} folders. Unpin one first. می‌توانید تا {n} پوشه را سنجاق کنید. ابتدا یکی را بردارید. + Pinned {folder} to Home {folder} به خانه سنجاق شد + No recent files فایل اخیری وجود ندارد + New blank PDF PDF خالی جدید + Create PDF from file… ایجاد PDF از فایل… + Create PDF from images… ایجاد PDF از تصاویر… + Create PDF from multiple files… ایجاد PDF از چند فایل… + Create separate PDFs… ایجاد PDFهای جداگانه… + Create PDF from clipboard ایجاد PDF از بریده دان + Combine files… ترکیب فایل‌ها… + Save ذخیره + Save as… ذخیره به‌عنوان… + Close file بستن فایل + Close all بستن همه + Revert باز‌گرداندن + Print… چاپ… + Document properties… ویژگی‌های سند… + Undo واگرد + Redo ازنو + Find… یافتن… + Advanced search… جستوجوی پیشرفته… + Copy pages رونوشت صفحه‌ها + Cut pages برش صفحه‌ها + Paste pages چسباندن صفحه‌ها + Fit visible تناسب با بخش قابل مشاهده + Marquee zoom بزرگنمایی ناحی‌های + Take a snapshot گرفتن تصویر لحظه ای + Column select انتخاب ستونی + Full screen mode حالت تمامصفحه + Read mode حالت مطالعه + Next tab زبانه بعدی + Previous tab زبانه قبلی + Switch light / dark theme تغییر بین پوسته روشن / تیره + Comments panel پنل یادداشت‌ها + Form fields panel پنل فیلد‌های فرم + Clear form پاک کردن فرم + Find tools and commands… یافتن ابزار‌ها و فرمان‌ها… + Zoom بزرگنمایی + Actual size اندازه واقعی + Zoom to page level بزرگنمایی تا سطح صفحه + Fit to width تناسب با عرض + Display theme پوسته نمایش + Side panels پنل‌های کناری + Enable Acrobat JavaScript فعال کردن Acrobat JavaScript + Flatten Fill & Sign when saving ادغام کردن پر کردن و امضا هنگام ذخیره + Text, marks and signatures become part of the page. Other comments stay editable. متن، نشانه‌ها و امضا‌ها بخشی از صفحه می‌شوند. سایر یادداشت‌ها قابل‌ویرایش می‌مانند. + OK تأیید + Join our Discord به دیسکورد ما بپیوندید + Close بستن + Next / previous match تطابق بعدی / قبلی + Copy selected text رونوشت متن انتخاب‌شده + Double-click دوبار کلیک + Select a word انتخاب یک واژه + Clear selection / close find پاک کردن انتخاب / بستن جستوجو + Zoom in / out (also pinch or {key}-scroll) بزرگنمایی / کوچکنمایی (همچنین با دو انگشت یا {key} + پیمایش) + Rotate view چرخش نما + First / last page صفحه اول / آخر + Previous / next page صفحه قبلی / بعدی + Delete selected pages (Organize) حذف صفحه‌های انتخاب‌شده (سازمان‌دهی) + Select all pages (Organize) انتخاب همه صفحه‌ها (سازمان‌دهی) + Home خانه + Open باز کردن + Open a PDF ({key}) باز کردن یک PDF ({key}) + Dark gray theme پوسته خاکستری تیره + Light theme پوسته روشن + Appearance mode حالت ظاهر + Light روشن + Dark تیره + Active فعال + Dark theme پوسته تیره + Appearance settings… تنظیمات ظاهر… + All tools همه ابزار‌ها + Read خواندن + Convert تبدیل + E-Sign امضای الکترونیکی + Print ({key}) چاپ ({key}) + Save ({key}) ذخیره ({key}) + Document properties ({key}) ویژگی‌های سند ({key}) + Find tools and commands یافتن ابزار‌ها و فرمان‌ها + Fit to height تناسب با ارتفاع + Zoom in بزرگنمایی + Zoom out کوچکنمایی + Rotate view clockwise چرخش نما در جهت ساعتگرد + Rotate view counterclockwise چرخش نما در جهت پادساعتگرد + Page navigation پیمایش صفحه + Previous view نمای قبلی + Next view نمای بعدی + Page display نمایش صفحه + Continuous scrolling پیمایش پیوسته + Show gaps between pages نمایش فاصله بین صفحه‌ها + Hide gaps to join page edges. Margins inside the PDF remain unchanged. فاصله‌ها را برای اتصال لبه‌های صفحه پنهان کنید. حاشیه‌های داخل PDF تغییر نمی‌کنند. + Two-page view نمای دو صفح‌های + Single page صفحه تکی + Show cover page in two-page view نمایش صفحه جلد در نمای دو صفح‌های + Use system setting استفاده از تنظیمات سیستم + Light gray خاکستری روشن + Dark gray خاکستری تیره + Comments یادداشت‌ها + Bookmarks نشانک‌ها + Fields فیلد‌ها + Layers لایه‌ها + Attachments پیوست‌ها + Signatures امضا‌ها + Accessibility Checker بررسی‌کننده دسترس‌پذیری + Search جستوجو + Compare مقایسه + Page thumbnails بندانگشتی صفحه‌ها + Form fields فیلد‌های فرم + Zoom out ({key}) کوچکنمایی ({key}) + Zoom in ({key}) بزرگنمایی ({key}) + Rotate view clockwise ({key}) چرخش نما در جهت ساعتگرد ({key}) + Toggle fit page / fit width تغییر بین تناسب با صفحه / تناسب با عرض + Next page صفحه بعدی + Previous page صفحه قبلی + Current page — type a page number or label (such as iv) and press Enter صفحه فعلی — یک شماره یا برچسب صفحه (مانند iv) وارد کنید و Enter را فشار دهید + Zoom — type a percentage and press Enter بزرگنمایی — یک درصد وارد کنید و Enter را فشار دهید + Welcome to PdfCraft به PdfCraft خوش آمدید + An open-source PDF workbench — local, private, and scriptable. یک میزکار PDF متن باز — محلی، خصوصی و قابل اسکریپت‌نویسی. + Join the ArtCraft community به جامعه ArtCraft بپیوندید + Get help, share feedback and follow development on Discord. کمک بگیرید، بازخورد بدهید و توسعه را در دیسکورد دنبال کنید. + Recommended tools ابزار‌های پیشنهادی + Use now همین حالا استفاده کنید + Open file باز کردن فایل + Recent اخیر + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. فایل‌هایی که در PdfCraft باز می‌کنید اینجا نمایش داده می‌شوند. برای باز کردن، یک PDF را هرجایی رها کنید. + Privacy حریم خصوصی + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft آفلاین کار می‌کند. بدون تله سنجی، بدون حساب کاربری و بدون پردازش ابری، مگر اینکه ارائه دهند‌ه ای اضافه کنید. + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. اسکریپت‌ها در یک محیط ایزوله بدون دسترسی به فایل یا شبکه اجرا می‌شوند. با خاموش بودن JavaScript، توابع استاندارد قالب‌بندی، اعتبارسنجی و محاسبه Acrobat همچنان کار می‌کنند. + Back to all tools بازگشت به همه ابزار‌ها + Close panel بستن پنل + View less نمایش کمتر + View more نمایش بیشتر + Available در دسترس + Ready آماده + Search tools and commands… جستوجوی ابزار‌ها و فرمان‌ها… + No matching tools ابزار منطبقی نیست + Tool ابزار + Command فرمان + Add a sticky note افزودن یادداشت چسبان + Add a text box افزودن کادر متن + Highlight text برجسته‌سازی متن + Underline text زیرخط متن + Strikethrough text خط خوردگی متن + Squiggly underline text زیرخط موجدار متن + Draw freehand ترسیم آزاد + Draw a line ترسیم خط + Draw an arrow ترسیم پیکان + Draw a rectangle ترسیم مستطیل + Draw an oval ترسیم بیضی + Draw a polygon ترسیم چند‌ضلعی + Draw connected lines ترسیم خطوط متصل + Draw a cloud ترسیم ابر + Add a callout افزودن فراخوان + Insert text درج متن + Replace text جای‌گزینی متن + Attach a file پیوست کردن فایل + Erase drawings پاک کردن ترسیم‌ها + Flatten comments ادغام یادداشت‌ها + Import comments… درونبرد یادداشت‌ها… + Add a stamp افزودن مهر + Export all comments to data file… برونبرد همه یادداشت‌ها به فایل داده… + Hide all comments پنهان کردن همه یادداشت‌ها + Summarize comments خلاصه‌سازی یادداشت‌ها + Import form data… درونبرد داده‌های فرم… + Export form data… برونبرد داده‌های فرم… + PDF/A… PDF/A… + Action Wizard… جادوگر اقدام… + Prepare for distribution… آماده‌سازی برای توزیع… + Optimize scanned documents… بهینه‌سازی اسناد اسکن شده… + Compare files… مقایسه فایل‌ها… + Detect form fields تشخیص فیلد‌های فرم + Merge data files into spreadsheet… ادغام فایل‌های داده در صفحهگسترده… + Flatten form fields ادغام فیلد‌های فرم + Prepare a form آماده‌سازی فرم + Tab order: by rows ترتیب زبانه: بر اساس ردیف‌ها + Tab order: by columns ترتیب زبانه: بر اساس ستون‌ها + Tab order: by document structure ترتیب زبانه: بر اساس ساختار سند + Add text افزودن متن + Edit text & images ویرایش متن و تصاویر + Add or edit links افزودن یا ویرایش پیوند‌ها + Create links from URLs ایجاد پیوند از URLها + Remove all links حذف همه پیوند‌ها + No links to remove پیوندی برای حذف وجود ندارد + Removed 1 link ۱ پیوند حذف شد + Removed {n} links {n} پیوند حذف شد + Add Bates numbering… افزودن شماره‌گذاری Bates… + Add image… افزودن تصویر… + Redact text and images سیاه‌کردن متن و تصاویر + Redact pages… سیاه‌کردن صفحه‌ها… + Find text and redact… یافتن متن و سیاه‌کردن… + Redaction properties… ویژگی‌های سیاه‌کردن… + Apply redactions… اعمال سیاه‌کردن‌ها… + Clear redaction marks پاک کردن نشانه‌های سیاه‌کردن + Remove hidden information… حذف اطلاعات پنهان… + Sanitize document… پاک‌سازی سند… + Field properties… ویژگی‌های فیلد… + Add a text field افزودن فیلد متن + Add a checkbox افزودن جعبه انتخاب + Add a radio button افزودن دکمه رادیویی + Add a drop-down list افزودن فهرست کشویی + Add a list box افزودن کادر فهرست + Add a button افزودن دکمه + Add an image field افزودن فیلد تصویر + Add a date field افزودن فیلد تاریخ + Add a digital signature field افزودن فیلد امضای دیجیتال + Digitally sign امضای دیجیتال + Certify (visible signature) گواهی (امضای قابل مشاهده) + Certify (invisible signature) گواهی (امضای نامرئی) + Validate all signatures اعتبارسنجی همه امضا‌ها + Signatures panel پنل امضا‌ها + Fill & Sign: add text پر کردن و امضا: افزودن متن + Fill & Sign: checkmark پر کردن و امضا: علامت تیک + Fill & Sign: cross پر کردن و امضا: ضربدر + Fill & Sign: dot پر کردن و امضا: نقطه + Fill & Sign: line پر کردن و امضا: خط + Fill & Sign: date پر کردن و امضا: تاریخ + Fill & Sign: sign پر کردن و امضا: امضا + Fill & Sign: initials پر کردن و امضا: حروف اول + Export to image… برونبرد به تصویر… + Reduce file size… کاهش اندازه فایل… + Optimize PDF… بهینه‌سازی PDF… + Export to text… برونبرد به متن… + Export to Word… برونبرد به Word… + Export to PowerPoint… برونبرد به PowerPoint… + Export to HTML… برونبرد به HTML… + Export to RTF… برونبرد به RTF… + JavaScript console… کنسول JavaScript… + Document JavaScripts… JavaScriptهای سند… + Recognize text… تشخیص متن… + Recognize text in multiple files… تشخیص متن در چند فایل… + Check for accessibility… بررسی دسترس‌پذیری… + Open accessibility report باز کردن گزارش دسترس‌پذیری + Change reading options… تغییر گزینه‌های خواندن… + Add alternate text… افزودن متن جانشین… + Export all images… برونبرد همه تصاویر… + Add header & footer… افزودن سرصفحه و پاصفحه… + Update header & footer… به‌روز‌رسانی سرصفحه و پاصفحه… + Remove header & footer حذف سرصفحه و پاصفحه + Add watermark… افزودن چاپ سایه… + Update watermark… به‌روز‌رسانی چاپ سایه… + Remove watermark حذف چاپسایه + Add background… افزودن پس‌زمینه… + Update background… به‌روز‌رسانی پس‌زمینه… + Remove background حذف پس‌زمینه + Protect using password… محافظت با گذرواژه… + Remove security حذف امنیت + Security properties… ویژگی‌های امنیت… + Organize pages سازمان‌دهی صفحه‌ها + New bookmark نشانک جدید + New bookmarks from structure نشانک‌های جدید از ساختار + Rotate pages clockwise چرخش صفحه‌ها در جهت ساعتگرد + Rotate pages counterclockwise چرخش صفحه‌ها در جهت پادساعتگرد + Delete pages حذف صفحه‌ها + Insert blank page درج صفحه خالی + Rotate pages… چرخش صفحه‌ها… + Duplicate pages تکثیر صفحه‌ها + Crop pages برش صفحه‌ها + Set page boxes… تنظیم کادر‌های صفحه… + Insert pages from file… درج صفحه‌ها از فایل… + Replace pages… جای‌گزینی صفحه‌ها… + Extract pages… استخراج صفحه‌ها… + Split document… تقسیم سند… + Number pages… شماره‌گذاری صفحه‌ها… + Keyboard shortcuts میانبر‌های صفحه کلید + Join the ArtCraft Discord به دیسکورد ArtCraft بپیوندید + PdfCraft web page صفحه وب PdfCraft + PdfCraft on GitHub PdfCraft در GitHub + ArtCraft website وبسایت ArtCraft + Check for updates… بررسی به‌روز‌رسانی‌ها… + About PdfCraft درباره PdfCraft + Export a PDF برونبرد یک PDF + Export to برونبرد به + Microsoft Word (.docx) Microsoft Word (.docx) + Spreadsheet (.xlsx) صفحه گسترده (.xlsx) + Presentation (.pptx) ارائه (.pptx) + Image (PNG) تصویر (PNG) + Export all images برونبرد همه تصاویر + HTML web page صفحه وب HTML + Rich Text Format (.rtf) قالب متن غنی (.rtf) + Text (plain) متن (ساده) + PostScript / EPS PostScript / EPS + Fill & Sign پر کردن و امضا + Fill پر کردن + Checkmark علامت تیک + Cross ضرب در + Dot نقطه + Line خط + Date تاریخ + Signature امضا + Initials حروف اول + Edit a PDF ویرایش یک PDF + Modify page تغییر صفحه + Rotate pages چرخش صفحه‌ها + Insert pages درج صفحه‌ها + Extract pages استخراج صفحه‌ها + Add content افزودن محتوا + Text متن + Image تصویر + Header and footer سرصفحه و پاصفحه + Watermark چاپ سایه + Link پیوند + Bates numbering شماره‌گذاری Bates + Background پس‌زمینه + Attach file پیوست کردن فایل + Create a PDF ایجاد یک PDF + Create from ایجاد از + Single file فایل تکی + Multiple files چند فایل + Separate PDFs PDFهای جداگانه + Images تصاویر + Clipboard بریده دان + Blank page صفحه خالی + Combine files ترکیب فایل‌ها + Combine ترکیب + Add files to combine افزودن فایل‌ها برای ترکیب + Page options گزینه‌های صفحه + Page grid شبکه صفحه + Rotate چرخش + Delete حذف + Extract استخراج + Insert درج + Replace جای‌گزینی + Split تفکیک + More بیشتر + Set page boxes تعیین جعبه‌های صفحه + Set page transitions تعیین انتقال‌های صفحه + Page labels برچسب‌های صفحه + Page transitions انتقال‌های صفحه + Add comments افزودن یادداشت‌ها + Markup نشانه‌گذاری + Sticky note یادداشت چسبان + Underline زیرخط + Strikethrough خط خوردگی + Squiggly underline زیرخط موجدار + Text box جعبه متن + Drawing ترسیم + Rectangle مستطیل + Oval بیضی + Arrow پیکان + Cloud ابر + Stamp مهر + Review باز‌بینی + Comment list فهرست یادداشت‌ها + Scan & OCR پویش و OCR + Recognize text شناسایی متن + In this file در این فایل + In multiple files در چندین فایل + Enhance scanned file بهبود فایل پویش‌شده + Correct recognized text تصحیح متن شناسایی‌شده + Protect a PDF محافظت از یک PDF + Protect محافظت + Protect with password محافظت با گذرواژه + Remove hidden information حذف اطلاعات پنهان + Advanced options گزینه‌های پیشرفته + Encrypt with certificate رمز‌گذاری با گواهی + Security properties ویژگی‌های امنیت + Redact a PDF سیاه‌کردن یک PDF + Redact سیاه‌کردن + Redact pages سیاه‌کردن صفحه‌ها + Find text and redact یافتن متن و سیاه‌کردن + Set properties تعیین ویژگی‌ها + Sanitize document پاک‌سازی سند + Compress a PDF فشرده‌سازی یک PDF + Optimize بهینه‌سازی + Reduce file size کاهش اندازه فایل + Advanced optimization بهینه‌سازی پیشرفته + Audit space usage بررسی میزان فضای مصرفی + Field list فهرست فیلد‌ها + Field properties ویژگی‌های فیلد + Tab order by rows ترتیب زبانه بر اساس سطر‌ها + Tab order by columns ترتیب زبانه بر اساس ستون‌ها + Import data درون‌ریزی داده‌ها + Export data برونبری داده‌ها + Add form components افزودن اجزای فرم + Text field فیلد متن + Checkbox کادر انتخاب + Radio button دکمه رادیویی + Drop-down list فهرست کشویی + List box جعبه فهرست + Button دکمه + Image field فیلد تصویر + Date field فیلد تاریخ + Digital signature امضای دیجیتال + Stamps مهر‌ها + Stamp palette پالت مهر‌ها + Use a certificate استفاده از یک گواهی + Certificates گواهی‌ها + Timestamp مهر زمانی + Use print production استفاده از تولید چاپ + Print production تولید چاپ + Output preview پیش‌نمایش خروجی + Preflight پیش‌بررسی + Convert colors تبدیل رنگ‌ها + Add printer marks افزودن نشانه‌های چاپگر + Fix hairlines اصلاح خطوط مویی + Measure objects اندازه‌گیری اشیا + Measure اندازه‌گیری + Measuring tool ابزار اندازه‌گیری + Geospatial location موقعیت مکانی جغرافیایی + Compare files مقایسه فایل‌ها + Select files to compare انتخاب فایل‌ها برای مقایسه + Use guided actions استفاده از کنش‌های هدایت‌شده + Actions list فهرست کنش‌ها + Action Wizard جادوگر کنش + Make accessible دسترس‌پذیر کردن + Prepare for distribution آماده‌سازی برای توزیع + Optimize scanned documents بهینه‌سازی اسناد پویش‌شده + Archive documents بایگانی اسناد + Prepare for accessibility آماده‌سازی برای دسترس‌پذیری + Accessibility دسترس‌پذیری + Automatically tag PDF برچسب‌گذاری خودکار PDF + Change reading options تغییر گزینه‌های خواندن + Check for accessibility بررسی دسترس‌پذیری + Add alternate text افزودن متن جانشین + Fix reading order اصلاح ترتیب خواندن + Apply PDF standards اعمال استاندارد‌های PDF + Standards استاندارد‌ها + Save as PDF/A ذخیره به‌صورت PDF/A + Save as PDF/X ذخیره به‌صورت PDF/X + Save as PDF/UA ذخیره به‌صورت PDF/UA + Add search index افزودن نمایه جستوجو + Index نمایه + Manage embedded index مدیریت نمایه جا‌سازی‌شده + Use JavaScript استفاده از JavaScript + JavaScript JavaScript + Console کنسول + Document JavaScripts JavaScriptهای سند + Document actions کنش‌های سند + AI assistant دستیار هوش مصنوعی + Bring your own model (off by default) مدل خودتان را بی‌آورید (به‌طور پیش‌فرض خاموش) + Summarize خلاصه‌سازی + Ask about this document پرسش درباره این سند + Translate ترجمه + pages صفحه‌ها + Planned برنامه‌ریزی‌شده + Planned for milestone برنامه‌ریزی‌شده برای نقطه عطف + open to see what it will include باز کنید تا ببینید چه چیزی را در بر می‌گیرد + Optional: needs an AI provider you configure اختیاری: به یک ارائه دهنده هوش مصنوعی که خودتان پیکر‌بندی می‌کنید نیاز دارد + Coming in milestone در راه در نقطه عطف + Items marked Ready work today. مواردی که با برچسب آماده مشخص شدهاند امروز کار می‌کنند. + Preview پیش‌نمایش + Edit fields ویرایش فیلد‌ها + Try the form as people filling it in will see it فرم را همان‌گونه که افرادی که آن را پر می‌کنند می‌بینند امتحان کنید + Redact all سیاه‌کردن همه + Clear all پاک کردن همه + Rotate page چرخش صفحه + Delete page حذف صفحه + Move page جابه‌جایی صفحه + Move pages جابه‌جایی صفحه‌ها + Duplicate page تکثیر صفحه + Replace page جای‌گزینی صفحه + Replace pages جای‌گزینی صفحه‌ها + Crop page برش صفحه + Cancel لغو + Don't save ذخیره نکن + Apply اعمال + Run اجرا + Clear پاک کردن + Sanitize پاک‌سازی + Mark all علامت‌گذاری همه + Save changes to “{name}” before closing? تغییرات «{name}» پیش از بستن ذخیره شود؟ + Your changes will be lost if you don't save them. اگر آن‌ها را ذخیره نکنید، تغییراتتان از دست می‌رود. + Password required گذرواژه لازم است + “{name}” is protected. Enter a password to open it. «{name}» محافظت‌شده است. برای باز کردن آن گذرواژ‌های وارد کنید. + Password گذرواژه + Incorrect password. Try again. گذرواژه نادرست است. دوباره تلاش کنید. + Recognize Text شناسایی متن + Password protection will be applied when you save محافظت با گذرواژه هنگام ذخیره اعمال می‌شود + Saved the print-ready PDF to {path} PDF آماده چاپ در {path} ذخیره شد + Could not save: {e} ذخیره نشد: {e} + Earlier in tab order پیش‌تر در ترتیب زبانه + Later in tab order بعد‌تر در ترتیب زبانه + Calculation order ترتیب محاسبه + Fix order اصلاح ترتیب + Move only what must move, so every field calculates after the fields it reads. فقط آنچه باید جابه جا شود را جابه جا کنید تا هر فیلد پس از فیلد‌هایی که می‌خواند محاسبه شود. + Earlier in calculation order پیش‌تر در ترتیب محاسبه + Later in calculation order بعد‌تر در ترتیب محاسبه + Too early خیلی زود + Reads {name}, which calculates later, so it shows an old value. {name} را می‌خواند که بعد‌تر محاسبه می‌شود، پس مقدار قدیمی را نشان می‌دهد. + Loop حلقه + Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} را می‌خواند که به نوبه خود آن را می‌خوانند. هیچ ترتیبی حلقه را اصلاح نمی‌کند: یکی از فرمول‌ها را تغییر دهید. + Not in order نامرتب + Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. در ترتیب محاسب‌های که در PDF ذخیره شده وجود ندارد: سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند. + Back to Prepare a form بازگشت به آماده‌سازی فرم + No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. هنوز هیچ فیلدی در این فرم مقدارش را محاسبه نمی‌کند. به یک فیلد در برگه محاسبه از ویژگی‌هایش یک محاسبه بدهید تا اینجا نمایش داده شود. + {names} and {last} {names} و {last} + {field} reads no other field {field} هیچ فیلد دی‌گری را نمی‌خواند + {field} reads {fields} {field} {fields} را می‌خواند + No field reads it هیچ فیلدی آن را نمی‌خواند + The order saved in the PDF needs tidying ترتیب ذخیره‌شده در PDF نیاز به مرتب‌سازی دارد + Moves only what must move فقط آنچه باید جابه جا شود را جابه جا می‌کند + Every field calculates after the fields it reads. هر فیلد پس از فیلد‌هایی که می‌خواند محاسبه می‌شود. + Every field outside a loop calculates after the fields it reads. هر فیلد بیرون از یک حلقه پس از فیلد‌هایی که می‌خواند محاسبه می‌شود. + {field} reads its own value {field} مقدار خودش را می‌خواند + {fields} read each other {fields} یکدیگر را می‌خوانند + No order can fix a loop. Change one of the formulas. هیچ ترتیبی حلقه را اصلاح نمی‌کند. یکی از فرمول‌ها را تغییر دهید. + Edit {field}'s formula ویرایش فرمول {field} + Order · top calculates first ترتیب · بالا اول محاسبه می‌شود + Script اسکریپت + Links on page پیوند‌های روی صفحه + Show on the page which fields each field reads روی صفحه نشان بده که هر فیلد کدام فیلد‌ها را می‌خواند + Drag a field to move it, or select it and press {up} or {down}. یک فیلد را برای جابه‌جایی بکشید، یا آن را انتخاب کنید و {up} یا {down} را فشار دهید. + From: از: + To: تا: + High Resolution تفکیک‌پذیری بالا + Print چاپ + Verify to see what the document needs. برای دیدن نیاز‌های سند، بررسی کنید. + Set Page Boxes تعیین جعبه‌های صفحه + Signature panel پنل امضا + {month} {day}, {y} {day} {month} {y} + The document's security settings don't allow filling in form fields تنظیمات امنیتی سند اجازه پر کردن فیلد‌های فرم را نمی‌دهد + {name} is read-only {name} فقط خواندنی است + {name} has no action {name} هیچ کنشی ندارد + Previous month ماه پیش + Next month ماه بعد + January ژانویه + February فوریه + March مارس + April آوریل + May مه + June ژوئن + July ژوئیه + August اوت + September سپتامبر + October اکتبر + November نوامبر + December دسامبر + Done انجام شد + This certificate is in your list of trusted certificates. این گواهی در فهرست گواهی‌های مورد اعتماد شماست. + This certificate is trusted through a certificate above it in the chain. این گواهی از طریق گواهی بالا‌تر از آن در زنجیره مورد اعتماد است. + This certificate is not trusted. Signatures made with it show an unknown identity. این گواهی مورد اعتماد نیست. امضا‌های ساخته‌شده با آن هویتی ناشناخته نشان می‌دهند. + Add to Trusted Certificates افزودن به گواهی‌های مورد اعتماد + Export… برونبری… + PDF Optimizer بهینه‌ساز PDF + Discard Objects دور انداختن اشیا + Discard User Data دور انداختن داده‌های کاربر + Clean Up پاک‌سازی + Color Images تصاویر رنگی + Grayscale Images تصاویر خاکستری + Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به + for images above برای تصاویر بالا‌تر از + Compression فشرده‌سازی + ZIP ZIP + Retain existing نگهداشتن موجود + Minimum کمینه + Low پایین + High بالا + Maximum بیشینه + Each image is measured where pages draw it; an image is replaced only if the result is smaller. هر تصویر همان‌جا که صفحه‌ها آن را ترسیم می‌کنند اندازه‌گیری می‌شود؛ تصویر تن‌ها در صورتی جایگزین می‌شود که نتیجه کوچک‌تر باشد. + Discard all links, actions and JavaScript دور انداختن همه پیوند‌ها، کنش‌ها و JavaScript + Discard alternate images دور انداختن تصاویر جانشین + Discard embedded page thumbnails دور انداختن تصاویر بندانگشتی جا‌سازی‌شده صفحه + Discard document tags دور انداختن برچسب‌های سند + Discard embedded print settings دور انداختن تنظیمات چاپ جا‌سازی‌شده + Discard bookmarks دور انداختن نشانک‌ها + Discard hidden layer content دور انداختن محتوای لایه پنهان + Discard all comments, forms and multimedia دور انداختن همه یادداشت‌ها، فرم‌ها و چند‌رسان‌های + Discard document information and metadata دور انداختن اطلاعات و فراداده‌های سند + Discard all object data (file attachments) دور انداختن همه داده‌های شیء (پیوست‌های فایل) + Discard private data of other applications دور انداختن داده‌های خصوصی سایر برنامه‌ها + Discard hidden text دور انداختن متن پنهان + Use Flate to encode streams that are not encoded استفاده از Flate برای کد‌گذاری جریان‌هایی که کد‌گذاری نشدهاند + In streams that use LZW encoding, use Flate instead در جریان‌هایی که از کد‌گذاری LZW استفاده می‌کنند، به‌جای آن از Flate استفاده کنید + Remove invalid links and bookmarks حذف پیوند‌ها و نشانک‌های نامعتبر + Remove unreferenced named destinations حذف مقصد‌های نامدار بدون ارجاع + Compress document structure (object streams) فشرده‌سازی ساختار سند (جریان‌های شیء) + Remove unused objects and merge identical ones حذف اشیای بدون استفاده و ادغام اشیای یکسان + Audit space usage… بررسی میزان فضای مصرفی… + Space Audit بررسی فضا + What takes up the space in this file, by kind of content. چه چیزی فضای این فایل را اشغال می‌کند، بر حسب نوع محتوا. + Bytes بایت + Content Streams جریان‌های محتوا + X Object Forms فرم‌های XObject + Structure Info اطلاعات ساختار + Thumbnails تصاویر بندانگشتی + Named Destinations مقصد‌های نامدار + Color Spaces فضا‌های رنگ + Shadings سایه زنی‌ها + Patterns (graphics objects) الگو‌ها (اشیای گرافیکی) + Extended Graphics States حالت‌های گرافیکی توسعه یافته + Embedded Files فایل‌های جا‌سازی‌شده + Document Overhead سربار سند + Total مجموع + Text Field Properties ویژگی‌های فیلد متن + Check Box Properties ویژگی‌های کادر انتخاب + Radio Button Properties ویژگی‌های دکمه رادیویی + Dropdown Properties ویژگی‌های فهرست کشویی + List Box Properties ویژگی‌های جعبه فهرست + Button Properties ویژگی‌های دکمه + Digital Signature Properties ویژگی‌های امضای دیجیتال + Position موقعیت + Options گزینه‌ها + Validate اعتبارسنجی + Calculate محاسبه + Actions کنش‌ها + This field is locked. Unlock it to change its properties. این فیلد قفل است. برای تغییر ویژگی‌هایش آن را باز کنید. + Tooltip: راهنمای ابزار: + Common Properties ویژگی‌های مشترک + Read Only فقط خواندنی + Required الزامی + Font Size: اندازه قلم: + Auto خودکار + Text Color: رنگ متن: + Borders and Colors حاشیه‌ها و رنگ‌ها + Border Color: رنگ حاشیه: + Fill Color: رنگ پرشدگی: + No color بدون رنگ + Beveled پخدار + Left: چپ: + Bottom: پایین: + Width: عرض: + Height: ارتفاع: + Rotation: چرخش: + Points from the page's bottom-left corner. نقاط از گوشه پایین چپ صفحه. + Alignment: تراز‌بندی: + Default Value: مقدار پیش‌فرض: + Multi-line چند‌خطی + Scroll long text پیمایش متن طولانی + Allow Rich Text Formatting اجازه قالب‌بندی متن غنی + Limit of محدودیت + characters نویسه + Field is used for file selection فیلد برای انتخاب فایل استفاده می‌شود + Check spelling بررسی املا + Comb of شان‌های + Export Value: {on} مقدار برونبری: {on} + Check box is checked by default کادر به‌طور پیش‌فرض علامت خورده است + Button is checked by default دکمه به‌طور پیش‌فرض علامت خورده است + Buttons with the same name and value are selected in unison دکمه‌هایی با نام و مقدار یکسان هم‌زمان انتخاب می‌شوند + Item: مورد: + Item List فهرست موارد + Up بالا + Delete {o} حذف {o} + Add the choices people pick from. گزینه‌هایی که افراد از میانشان انتخاب می‌کنند را بی‌فزایید. + Default: پیش‌فرض: + (none) (هیچکدام) + Sort items مرتب‌سازی موارد + Allow user to enter custom text اجازه دادن به کاربر برای وارد کردن متن سفارشی + Multiple selection انتخاب چند‌گانه + Commit selected value immediately ثبت فوری مقدار انتخاب‌شده + Number عدد + Percentage درصد + Time زمان + Special ویژه + Select format category: دسته قالب را انتخاب کنید: + The value is shown as typed. مقدار همان‌گونه که تایپ شده نشان داده می‌شود. + Decimal places: ارقام اعشار: + Separator style: سبک جدا‌کننده: + Currency symbol: نماد واحد پول: + Before the number پیش از عدد + Negative number style: سبک اعداد منفی: + Format: قالب: + Custom: سفارشی: + Zip Code کد پستی + Zip Code + 4 کد پستی + ۴ + Phone Number شماره تلفن + Social Security Number شماره امنیت اجتماعی + Arbitrary Mask ماسک دلبخواه + Mask: ماسک: + 9 digit, A letter, O letter or digit, X any character ۹: رقم؛ A: حرف؛ O: حرف یا رقم؛ X: هر نویسه + Example: {sample} نمونه: {sample} + Field value is not validated مقدار فیلد اعتبارسنجی نمی‌شود + Field value is in range: مقدار فیلد در بازه است: + Add an Action افزودن یک کنش + Select Trigger: انتخاب محرک: + Mouse Up رها کردن دکمه ماوس + Mouse Down فشار دادن دکمه ماوس + Mouse Enter ورود ماوس + Mouse Exit خروج ماوس + On Focus هنگام دریافت فوکوس + On Blur هنگام از دست دادن فوکوس + Select Action: انتخاب کنش: + Open a web link باز کردن یک پیوند وب + Reset a form باز‌نشانی یک فرم + Execute a menu item اجرای یک مورد منو + Show a field نمایش یک فیلد + Hide a field پنهان کردن یک فیلد + Submit a form ارسال یک فرم + Fields to reset (comma-separated; empty: all) فیلد‌های باز‌نشانی (جدا شده با کاما؛ خالی: همه) + Print, NextPage, PrevPage, FirstPage or LastPage Print، NextPage، PrevPage، FirstPage یا LastPage + Field names (comma-separated) نام‌های فیلد (جدا شده با کاما) + URL to submit to URL برای ارسال به آن + Script: اسکریپت: + Value: مقدار: + No actions بدون کنش + Value is not calculated مقدار محاسبه نمی‌شود + Value is the مقدار برابر است با + sum (+) مجموع (+) + product (x) حاصلضرب (x) + average میانگین + minimum کمینه + maximum بیشینه + of the following fields: از فیلد‌های زیر: + Simplified field notation: نشانه‌گذاری ساده‌شده فیلد: + Field names with + - * / and parentheses; put \\ before spaces in names. نام فیلد‌ها با + - * / و پرانتز‌ها؛ قبل از فاصله‌ها در نام‌ها \\ بگذارید. + Finding document fonts… یافتن قلم‌های سند… + Installed fonts… قلم‌های نصب‌شده… + Keep source fonts حفظ قلم‌های منبع + Load font… بار‌گذاری قلم… + No openly licensed document fonts found. Load a font file. هیچ قلم سند با مجوز باز یافت نشد. یک فایل قلم بار‌گذاری کنید. + Format text قالب‌بندی متن + Bold پررنگ + Italic کج + Align left تراز‌بندی چپ + Centre وسطچین + Align right تراز‌بندی راست + Justify هم‌تراز + Thai and Arabic use regular fallback fonts when available. تایلندی و عربی در صورت موجود بودن از قلم‌های جانشین عادی استفاده می‌کنند. + Edit image ویرایش تصویر + Rotate counterclockwise چرخش پادساعتگرد + Rotate clockwise چرخش ساعتگرد + Flip horizontal وارونگی افقی + Flip vertical وارونگی عمودی + Replace image جای‌گزینی تصویر + Crop (% trimmed from each side) برش (درصد کاسته‌شده از هر طرف) + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. روی صفحه کلیک کنید تا متن اضافه شود؛ هر کلیک یک کادر جدید آغاز می‌کند و ✓ پایان می‌دهد. برای جابه‌جایی موارد را بکشید، برای تغییر اندازه گوش‌های را بکشید و برای ویرایش متن دوبار کلیک کنید. + Choose an image انتخاب یک تصویر + Export to Image صدور به تصویر + Export to Text صدور به متن + Export All Images صدور همه تصاویر + Resolution تفکیک‌پذیری + Format قالب + Quality کیفیت + One {f} file per page, named after the document. یک فایل {f} در هر صفحه، نام‌گذاری‌شده بر اساس سند. + Exclude images smaller than کنار گذاشتن تصاویر کوچک‌تر از + No limit بدون محدودیت + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. هر تصویر یک بار، نام‌گذاری‌شده بر اساس سند و صفحه. تصاویر JPEG بدون تغییر ذخیره می‌شوند؛ بقیه به‌صورت PNG. + Plain text in reading order; pages are separated by form feeds. متن ساده به ترتیب خواندن؛ صفحه‌ها با نویسه‌های فرمفید از هم جدا می‌شوند. + Create {what} ایجاد {what} + initials حروف اول + signature امضا + Create Custom Stamp ایجاد مهر سفارشی + From {file} از {file} + Category: دسته: + e.g. My stamps مثلاً مهر‌های من + Name: نام: + Custom سفارشی + Delete stamp حذف مهر + Create custom stamp… ایجاد مهر سفارشی… + From a PDF page or an image از یک صفحه PDF یا یک تصویر + Line spacing فاصله خطوط + Character spacing فاصله نویسه‌ها + Horizontal scale مقیاس افقی + Rotate Clockwise چرخش ساعتگرد + Rotate Counterclockwise چرخش پادساعتگرد + Flip Horizontal وارونگی افقی + Flip Vertical وارونگی عمودی + Replace Image… جای‌گزینی تصویر… + Save Image As… ذخیره تصویر به‌عنوان… + Copy Image کپی تصویر + The image has been copied ({w} × {h} pixels) تصویر کپی شد ({w} × {h} پیکسل) + Couldn't copy the image: {e} کپی تصویر انجام نشد: {e} + Copy Image in Original Format کپی تصویر در قالب اصلی + The image has been copied as a {ext} file تصویر به‌صورت یک فایل {ext} کپی شد + The page has changed; select the image again صفحه تغییر کرده است؛ تصویر را دوباره انتخاب کنید + Check for updates بررسی به‌روز‌رسانی‌ها + No check has run yet. هنوز هیچ بررسیای انجام نشده است. + Checking for a newer version… بررسی برای نگارش تازه‌تر… + PdfCraft {v} is available. PdfCraft {v} در دسترس است. + You have version {c}. Download the new version from its release page. شما نگارش {c} را دارید. نگارش جدید را از صفحه انتشار آن دانلود کنید. + PdfCraft {c} is up to date. PdfCraft {c} به‌روز است. + Couldn't check for updates: {e} بررسی به‌روز‌رسانی‌ها انجام نشد: {e} + You have version {c}. All releases are listed at {page}. شما نگارش {c} را دارید. همه انتشار‌ها در {page} فهرست شدهاند. + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. آخرین انتشار را از GitHub می‌پرسد. هیچ چیزی به‌طور خودکار دانلود یا نصب نمی‌شود. + Download دانلود + Later بعداً + none هیچکدام + Declared conformance: {shown} انطباق اعلام‌شده: {shown} + Output intent: {list} قصد خروجی: {list} + Conformance level: سطح انطباق: + No problems found: the document conforms to {level}. مشکلی یافت نشد: سند با {level} منطبق است. + (page {p}) (صفحه {p}) + · not fixable here · اینجا قابل رفع نیست + Verify تأیید + Box کادر + Units واحد‌ها + Set to zero تنظیم به صفر + Current page ({n}) صفحه کنونی ({n}) + Subset زیرمجموعه + Update به‌روز‌رسانی + Add افزودن + {verb} Header and Footer {verb} سرصفحه و پاصفحه + {verb} Watermark {verb} واترمارک + {verb} Background {verb} پس‌زمینه + Margins حاشیه‌ها + Left Header Text متن سرصفحه چپ + Center Header Text متن سرصفحه وسط + Right Header Text متن سرصفحه راست + Left Footer Text متن پاصفحه چپ + Center Footer Text متن پاصفحه وسط + Right Footer Text متن پاصفحه راست + Insert Page Number درج شماره صفحه + Insert Date درج تاریخ + Start page number شماره صفحه شروع + Shrink the page content to make room for the header and footer کوچک کردن محتوای صفحه برای ایجاد جا برای سرصفحه و پاصفحه + Source منبع + Fit to page اندازه با صفحه + Rotation چرخش + Appear behind page نمایش پشت صفحه + Appear on top of page نمایش روی صفحه + Offset from centre فاصله از مرکز + From colour از رنگ + Page Range Options گزینه‌های بازه صفحه + Preview: page {p} of {n} پیش‌نمایش: صفحه {p} از {n} + Applies to 1 page روی ۱ صفحه اعمال می‌شود + Applies to {n} pages روی {n} صفحه اعمال می‌شود + Replaces the existing one on those pages. مورد موجود در آن صفحه‌ها را جایگزین می‌کند. + PDF or image PDF یا تصویر + No file chosen هیچ فایلی انتخاب نشده + Page number شماره صفحه + Scale relative to target page مقیاس نسبت به صفحه هدف + Link Properties ویژگی‌های پیوند + Create Link ایجاد پیوند + Link Appearance ظاهر پیوند + Link Type: نوع پیوند: + Visible Rectangle مستطیل قابل مشاهده + Invisible Rectangle مستطیل نامرئی + Highlight Style: سبک برجسته‌سازی: + Invert وارون + Outline خط دور + Inset فرورفته + Line Thickness: ضخامت خط: + Thin نازک + Thick ضخیم + Line Style: سبک خط: + Solid پیوسته + Dashed خطچین + Color: رنگ: + Link Action کنش پیوند + Open a web page باز کردن یک صفحه وب + Go to a page view رفتن به نمایش صفحه + Type the web address. نشانی وب را تایپ کنید. + This document has no signatures. این سند هیچ امضایی ندارد. + The Keychain's digital IDs couldn't be listed: {e} شناسه‌های دیجیتال Keychain فهرست نشدند: {e} + Signed and saved to {path} امضا و در {path} ذخیره شد + Couldn't open revision {n}: {e} باز کردن باز‌نگری {n} انجام نشد: {e} + Couldn't open the signed version: {e} باز کردن نگارش امضا‌شده انجام نشد: {e} + Sign with a Digital ID امضا با شناسه دیجیتال + Choose the digital ID that you want to use for signing: شناسه دیجیتالی را که می‌خواهید برای امضا استفاده کنید انتخاب کنید: + Keychain · Keychain · + Windows store · فروشگاه Windows · + Issued by: صادر‌شده توسط: + , Expires: ، انقضا: + Digitally signed by امضا‌شده دیجیتالی توسط + DN: DN: + Reason: دلیل: + Location: مکان: + Date: تاریخ: + The labels are in English: the signature's font can't draw them in this language. برچسب‌ها به انگلیسی هستند: قلم امضا نمی‌تواند آن‌ها را به این زبان بکشد. + No digital IDs yet. هنوز هیچ شناسه دیجیتالی وجود ندارد. + Configure New Digital ID پیکر‌بندی شناسه دیجیتال جدید + Remove "{name}" from this list? The file itself is not deleted. «{name}» از این فهرست حذف شود؟ خود فایل حذف نمی‌شود. + Continue ادامه + Configure a Digital ID for Signing پیکر‌بندی یک شناسه دیجیتال برای امضا + Use a Digital ID from a file استفاده از یک شناسه دیجیتال از یک فایل + Create a new Digital ID (self-signed, saved to a password-protected file) ایجاد یک شناسه دیجیتال جدید (خودامضا، ذخیره‌شده در فایلی محافظت‌شده با گذرواژه) + Name نام + Organizational Unit واحد سازمانی + Organization Name نام سازمان + Email Address نشانی ایمیل + Country/Region کشور/منطقه + Key Algorithm الگوریتم کلید + Confirm Password تأیید گذرواژه + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. برای امضا‌های دیجیتال، به مدت ۵ سال معتبر است. شناسه‌های خودامضا معمولاً مورد اعتماد دیگران نیستند. + File (.p12, .pfx) فایل (.p12، .pfx) + Digital ID شناسه دیجیتال + Browse… مرور… + {verb} as "{name}" {verb} به‌عنوان «{name}» + Certify گواهی کردن + The signature will be invisible. امضا نامرئی خواهد بود. + Distinguished name نام ممتاز + Labels برچسب‌ها + Graphic نگاره + Signer's name نام امضا‌کننده + Fill & Sign signature امضای پُرکردن و امضا + Choose image… انتخاب تصویر… + Permitted actions after certifying کنش‌های مجاز پس از گواهی کردن + No changes allowed هیچ تغییری مجاز نیست + Annotations, form fill-in, and digital signatures حاشیه‌نویسی‌ها، پُرکردن فرم و امضا‌های دیجیتال + Form fill-in and digital signatures پُرکردن فرم و امضا‌های دیجیتال + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. کلید در Keychain macOS است، که ممکن است برای اجازه دادن به PdfCraft برای استفاده از آن درخواست کند. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. کلید در مخزن گواهی Windows است، که ممکن است برای اجازه دادن به PdfCraft برای استفاده از آن درخواست کند. + Digital ID password گذرواژه شناسه دیجیتال + Back بازگشت + Validate all اعتبارسنجی همه + Sign this field… امضای این فیلد… + Signature is valid: امضا معتبر است: + Signature validity is UNKNOWN: اعتبار امضا ناشناخته است: + Signature is INVALID: امضا نامعتبر است: + Signature Details جزئیات امضا + Reason دلیل + Signing time زمان امضا + Algorithm الگوریتم + Signer امضا‌کننده + Issued by صادر‌شده توسط + Valid معتبر + Field فیلد + Field: {field} on page {p} فیلد: {field} در صفحه {p} + Add to trusted certificates افزودن به گواهی‌های مورد اعتماد + View signed version نمایش نگارش امضا‌شده + Show certificate… نمایش گواهی… + Export certificate… صدور گواهی… + Certificate Viewer نمایشگر گواهی + This dialog shows the details of a certificate and its chain. این گفتوگو جزئیات یک گواهی و زنجیره آن را نشان می‌دهد. + Trust اعتماد + Issued to صادر‌شده برای + Valid from معتبر از + Valid to معتبر تا + Intended usage کاربرد موردنظر + Any هر + Version نگارش + Serial number شماره سریال + Issuer صادر‌کننده + Validity starts آغاز اعتبار + Validity ends پایان اعتبار + Public key کلید عمومی + Key usage کاربرد کلید + Not present موجود نیست + Self-signed خودامضا + Edit Action ویرایش کنش + New Action کنش جدید + Action name: نام کنش: + Description: توضیح: + Steps گام‌ها + Add the steps this action runs, in order. گام‌هایی را که این کنش به ترتیب اجرا می‌کند اضافه کنید. + Add Step افزودن گام + An action with this name already exists. کنشی با این نام از قبل وجود دارد. + New Action… کنش جدید… + Edit… ویرایش… + Add watermark افزودن واترمارک + Add header افزودن سرصفحه + Add footer افزودن پاصفحه + Set document title تعیین عنوان سند + Run a JavaScript اجرای یک JavaScript + Prepare for Distribution آماده‌سازی برای توزیع + Remove hidden information and flatten comments and form fields, so recipients see only the pages. حذف اطلاعات پنهان و تخت‌کردن یادداشت‌ها و فیلد‌های فرم، تا گیرندگان فقط صفحه‌ها را ببینند. + Optimize Scanned Documents بهینه‌سازی اسناد اسک‌نشده + Make scans searchable, then make them smaller. اسکن‌ها را قابل جستوجو کنید، سپس حجمشان را کاهش دهید. + Add Page Numbers افزودن شماره‌های صفحه + Number every page in the footer. هر صفحه را در پاصفحه شماره‌گذاری کنید. + Mark as Confidential علامت‌گذاری به‌عنوان محرمانه + A diagonal CONFIDENTIAL watermark on every page. یک واترمارک مورب CONFIDENTIAL روی هر صفحه. + Sanitize Documents پاک‌سازی اسناد + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. حذف اطلاعات پنهان، فراداده، اسکریپت‌ها و محتوای جا‌سازی‌شده، و باز‌نویسی فایل‌ها. + Type text تایپ متن + Text to insert متن برای درج + Inserted text متن درج‌شده + Replacement text متن جایگزین + Edit text… ویرایش متن… + Add a sticky note here افزودن یک یادداشت چسبان در اینجا + {kind} Properties ویژگی‌های {kind} + Appearance ظاهر + General عمومی + Review History تاریخچه باز‌بینی + This comment's appearance can't be changed yet. ظاهر این یادداشت هنوز قابل تغییر نیست. + Icon نماد + Thickness ضخامت + Line ending پایان خط + Modified تغییریافته + No status has been set. هیچ وضعیتی تعیین نشده است. + Compare Files مقایسه فایل‌ها + Old file: فایل قدیمی: + Open the older version first ابتدا نگارش قدیمی‌تر را باز کنید + New file: فایل جدید: + Text is compared word by word; the differences are shown on the new file. متن واژهبهواژه مقایسه می‌شود؛ تفاوت‌ها روی فایل جدید نشان داده می‌شوند. + Compare this file with an older version: Compare files… in All tools. این فایل را با یک نگارش قدیمی‌تر مقایسه کنید: مقایسه فایل‌ها… در همه ابزار‌ها. + No differences in the text. هیچ تفاوتی در متن نیست. + {n} {kind} {n} {kind} + Inserted درج‌شده + Deleted حذف‌شده + Replaced جایگزی‌نشده + Visual بصری + Mark as comments علامت‌گذاری به‌عنوان یادداشت + Report… گزارش… + {kind} · page {p} {kind} · صفحه {p} + Set Alternate Text تعیین متن جانشین + This document has no tagged figures. این سند هیچ شکل برچسب گذاری شد‌ه ای ندارد. + on page {p} در صفحه {p} + Figure {i} of {n}{page} شکل {i} از {n}{page} + No preview بدون پیش‌نمایش + Alternate text متن جانشین + Describe the figure توصیف شکل + Decorative figure شکل تزئینی + Previous figure شکل قبلی + Next figure شکل بعدی + Save & Close ذخیره و بستن + Accessibility Checker Options گزینه‌های بررسی‌کننده دسترس‌پذیری + Report Options گزینه‌های گزارش + Create accessibility report ایجاد گزارش دسترس‌پذیری + Page Range بازه صفحه + All pages in document همه صفحه‌های سند + Checking Options ({on} of 32 in all categories) گزینه‌های بررسی ({on} از ۳۲ در همه دسته‌ها) + Show this dialog when the Checker starts نمایش این گفتوگو هنگام شروع بررسی‌کننده + Select All انتخاب همه + Clear All پاک کردن همه + Start Checking شروع بررسی + This document hasn't been checked yet. این سند هنوز بررسی نشده است. + No issues found مشکلی یافت نشد + 1 issue ۱ مشکل + {n} issues {n} مشکل + {label} (1 issue) {label} (۱ مشکل) + {label} ({n} issues) {label} ({n} مشکل) + Check again بررسی دوباره + Report گزارش + Show the accessibility report نمایش گزارش دسترس‌پذیری + Collapse جمع کردن + Expand باز کردن + {rule} - {status} {rule} - {status} + Passed موفق + Failed ناموفق + Needs manual check نیاز‌مند بررسی دستی + Skipped نادیده‌گرفته‌شده + Fix اصلاح + Skip Rule نادیده‌گرفتن قانون + Explain توضیح + Check Again بررسی مجدد + Show Report نمایش گزارش + Options… گزینه‌ها… + Go to the page رفتن به صفحه + Page Content محتوای صفحه + Forms فرم‌ها + Alternate Text متن جایگزین + Tables جدول‌ها + Lists فهرست‌ها + Headings سرتیتر‌ها + Accessibility permission flag پرچم مجوز دسترس‌پذیری + Accessibility permission flag is set پرچم مجوز دسترس‌پذیری تنظیم شده است + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. تنظیمات امنیت باید به فناوری کمکی اجازه دهد محتوا را بخواند («فعال‌کردن دسترسی متنی برای دستگاه‌های صفحه خوان»). امنیت سند را تغییر دهید تا اجازه داده شود. + Image-only PDF PDF فقطتصویری + Document is not image-only PDF سند PDF فقطتصویری نیست + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. صفحه‌هایی که تن‌ها تصویر متن هستند را نمی‌توان با صدای بلند خواند یا جستوجو کرد. متن را تشخیص دهید (OCR) تا لایه متنی افزوده شود. + Document is tagged PDF سند PDF برچسبدار است + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). برچسب‌ها به سند ساختاری منطقی می‌دهند که فناوری کمکی از آن پیروی می‌کند. سند را برچسب بزنید (برچسبزدن خودکار PDF). + Logical Reading Order ترتیب خواندن منطقی + Document structure provides a logical reading order ساختار سند ترتیب خواندن منطقی را فراهم می‌کند + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. بررسی کنید که برچسب‌ها به همان ترتیبی خوانده شوند که یک خواننده بینا می‌خواند. از ابزار ترتیب خواندن یا پنل برچسب‌ها برای اصلاح آن استفاده کنید. + Primary language زبان اصلی + Text language is specified زبان متن مشخص شده است + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. صفحهخوان‌ها صدا را بر پایه زبان سند انتخاب می‌کنند. آن را در ویژگی‌های سند > پیشرفته > زبان تنظیم کنید. + Document title is showing in title bar عنوان سند در نوار عنوان نمایش داده می‌شود + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. پنجره باید به‌جای نام فایل عنوانی معنادار نشان دهد. عنوانی تنظیم کنید و نمایش اولیه > نمایش: عنوان سند. + Bookmarks are present in large documents نشانک‌ها در اسناد بزرگ وجود دارند + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. اسناد بیش از ۲۰ صفحه برای پیمایش به نشانک نیاز دارند. برای بخش‌های اصلی نشانک بی‌فزایید. + Color contrast کنتراست رنگ + Document has appropriate color contrast سند کنتراست رنگ مناسب دارد + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. متن باید برای افراد کمبینا بهاندازه کافی با پس‌زمینه کنتراست داشته باشد. رنگ‌ها را چشمی یا با ابزار کنتراست بررسی کنید. + Tagged content محتوای برچسبدار + All page content is tagged تمام محتوای صفحه برچسبدار است + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. هر چیزی در صفحه باید برچسب بخورد یا به‌عنوان مصنوع (تزئین، سرصفحه، پاصفحه) علامت‌گذاری شود. محتوای بدون برچسب را برچسب بزنید. + Tagged annotations یادداشت‌های برچسبدار + All annotations are tagged تمام یادداشت‌ها برچسبدار هستند + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. یادداشت‌ها، پیوند‌ها و فیلد‌ها باید در درخت برچسب باشند تا اعلام شوند. یادداشت‌ها را برچسب بزنید. + Tab order ترتیب تب + Tab order is consistent with structure order ترتیب تب با ترتیب ساختار سازگار است + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. صفحه‌های دارای پیوند، یادداشت یا فیلد باید به ترتیب ساختار تب شوند. اصلاح، ترتیب تب هر صفحه را روی استفاده از ساختار سند تنظیم می‌کند. + Character encoding رمز‌گذاری نویسه + Reliable character encoding is provided رمز‌گذاری نویسه قابل‌اطمینان فراهم شده است + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. قلم‌ها باید نویسه‌های خود را به یونیکد نگاشت کنند تا متن خوانده و رو‌نویسی شود. قلم‌ها را اصلاح کنید یا PDF را دوباره بسازید. + Tagged multimedia چند‌رسان‌های برچسبدار + All multimedia objects are tagged تمام اشیای چند‌رسان‌های برچسبدار هستند + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. ویدئو، صدا و چند‌رسان‌های غنی باید برچسب بخورند تا اعلام شوند. یادداشت‌های چند‌رسانه ای را برچسب بزنید. + Screen flicker سوسوی صفحه + Page will not cause screen flicker صفحه باعث سوسوی صفحه نمی‌شود + Animation that flashes can cause seizures. Check scripts and media for flicker. پویانمایی چشمکزن می‌تواند باعث تشنج شود. اسکریپت‌ها و رسانه‌ها را از نظر سوسو بررسی کنید. + Scripts اسکریپت‌ها + No inaccessible scripts بدون اسکریپت غیر‌قابلدسترس + Timed responses پاسخ‌های زماندار + Page does not require timed responses صفحه به پاسخ‌های زماندار نیاز ندارد + People may need more time to respond. Check that nothing has a time limit. ممکن است افراد به زمان بیشتری برای پاسخ نیاز داشته باشند. بررسی کنید که چیزی محدودیت زمانی نداشته باشد. + Navigation links پیوند‌های پیمایش + Navigation links are not repetitive پیوند‌های پیمایش تکراری نیستند + Repeated navigation links should be skippable. Check links that appear on every page. پیوند‌های پیمایش تکراری باید قابل‌نادیدهگرفتن باشند. پیوند‌هایی را که در هر صفحه ظاهر می‌شوند بررسی کنید. + Tagged form fields فیلد‌های فرم برچسبدار + All form fields are tagged تمام فیلد‌های فرم برچسبدار هستند + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. فیلد‌های فرم باید در درخت برچسب (برچسب‌های Form) باشند تا اعلام شوند. فیلد‌های فرم را برچسب بزنید. + Field descriptions توضیحات فیلد + All form fields have description تمام فیلد‌های فرم توضیح دارند + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. هر فیلد به راهنمای ابزار نیاز دارد که بگوید چه چیزی وارد شود. یکی در ویژگی‌های فیلد > عمومی > راهنمای ابزار بی‌فزایید. + Figures alternate text متن جایگزین شکل‌ها + Figures require alternate text شکل‌ها به متن جایگزین نیاز دارند + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. تصویر‌ها به متن جای‌گزینی نیاز دارند که آن‌ها را توصیف کند، یا باید تزئینی علامت‌گذاری شوند. به هر شکل متن جایگزین بی‌فزایید. + Nested alternate text متن جایگزین تودرتو + Alternate text that will never be read متن جای‌گزینی که هرگز خوانده نمی‌شود + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. متن جایگزین درون عنصری که از قبل متن جایگزین دارد هرگز خوانده نمی‌شود. متن جایگزین درونی را حذف کنید. + Associated with content مرتبط با محتوا + Alternate text must be associated with some content متن جایگزین باید با محتوایی مرتبط باشد + Alternate text on an element without page content is never reached. Remove it or attach the element to content. متن جایگزین روی عنصری بدون محتوای صفحه هرگز در دسترس قرار نمی‌گیرد. آن را حذف کنید یا عنصر را به محتوا پیوند دهید. + Hides annotation یادداشت را پنهان می‌کند + Alternate text should not hide annotation متن جایگزین نباید یادداشت را پنهان کند + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. متن جایگزین روی عنصری که شامل پیوند یا فیلد است، آن یادداشت را از فناوری کمکی پنهان می‌کند. متن جایگزین را جابهجا کنید. + Other elements alternate text متن جایگزین سایر عناصر + Other elements that require alternate text سایر عناصری که به متن جایگزین نیاز دارند + Formulas and similar non-text elements need alternate text. Add alternate text to them. فرمول‌ها و عناصر غیر‌متنی مشابه به متن جایگزین نیاز دارند. به آن‌ها متن جایگزین بی‌فزایید. + Rows سطر‌ها + TR must be a child of Table, THead, TBody, or TFoot TR باید فرزند Table، THead، TBody یا TFoot باشد + Table cells belong in a row. Fix the table's tags. خانه‌های جدول درون سطر قرار می‌گیرند. برچسب‌های جدول را اصلاح کنید. + Headers سرصفحه‌ها + Tables should have headers جدول‌ها باید سرصفحه داشته باشند + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. خانه‌های سرصفحه (TH) به شنوندگان می‌گویند هر ستون یا سطر چه دارد. خانه‌های سرصفحه را علامت‌گذاری کنید. + Regularity نظم + Tables must contain the same number of columns in each row and rows in each column جدول‌ها باید در هر سطر تعداد ستون یکسان و در هر ستون تعداد سطر یکسان داشته باشند + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). جدول‌های نامنظم پیمایش خانه‌ها را گیج‌کننده می‌کنند. به هر سطر تعداد یکسان خانه بدهید (با احتساب ادغام‌ها). + Summary خلاصه + Tables must have a summary جدول‌ها باید خلاصه داشته باشند + A summary describes the table's purpose and layout. Add a Summary attribute to each table. خلاصه هدف و چیدمان جدول را توصیف می‌کند. به هر جدول ویژگی Summary بی‌فزایید. + List items عناصر فهرست + LI must be a child of L LI باید فرزند L باشد + List items belong in a list. Fix the list's tags. عناصر فهرست درون فهرست قرار می‌گیرند. برچسب‌های فهرست را اصلاح کنید. + Lbl and LBody Lbl و LBody + Lbl and LBody must be children of LI Lbl و LBody باید فرزند LI باشند + List labels and bodies belong in a list item. Fix the list's tags. برچسب‌ها و بدنه‌های فهرست درون عنصر فهرست قرار می‌گیرند. برچسب‌های فهرست را اصلاح کنید. + Appropriate nesting تودرتویی مناسب + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. سطوح سرتیتر نباید پرش کنند (یک H1 و سپس H3). سرتیتر‌ها را دوباره برچسب بزنید. + TH and TD TH و TD + TH and TD must be children of TR TH و TD باید فرزند TR باشند + Mark Page Range علامت‌گذاری بازه صفحه + Pages from صفحه‌ها از + Single word or phrase واژه یا عبارت تکی + Text to find متن برای یافتن + Patterns الگو‌ها + Phone Numbers شماره‌های تلفن + Email Addresses نشانی‌های رایانامه + Credit Cards کارت‌های اعتباری + Social Security Numbers شماره‌های امنیت اجتماعی + Dates تاریخ‌ها + Text in images isn't found; recognise text (OCR) first. متن درون تصویر‌ها یافت نمی‌شود؛ نخست متن را تشخیص دهید (OCR). + No matches. بدون تطابق. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. برخی نشانک‌ها اینجا نمایش داده نمی‌شوند: پنل تعداد محدودی را فهرست می‌کند، با عمق تودرتویی محدود. + {n} match(es) marked for redaction. {n} تطابق برای سیاه‌کردن علامت‌گذاری شد. + Multiple words or phrases چندین واژه یا عبارت + One word or phrase per line یک واژه یا عبارت در هر خط + Import list… درون‌ریزی فهرست… + {n} word(s) or phrase(s) {n} واژه یا عبارت + {name} is too large for a word list (at most 1 MB). {name} برای فهرست واژه‌ها بیش‌از‌حد بزرگ است (حداکثر ۱ مگابایت). + Redaction Tool Properties ویژگی‌های ابزار سیاه‌کردن + Redacted area fill colour: رنگ پر‌کردن ناحیه سیاه‌شده: + Use overlay text استفاده از متن روکش + Custom text: متن سفارشی: + Redaction code: کد سیاه‌کردن: + Font: قلم: + Font size: اندازه قلم: + Font size اندازه قلم + Auto-size text to fit redaction region تغییر خودکار اندازه متن برای جا شدن در ناحیه سیاه‌کردن + Font colour: رنگ قلم: + Repeat overlay text تکرار متن روکش + Text alignment: تراز‌بندی متن: + Center وسط + Apply redactions اعمال سیاه‌کردن‌ها + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. در آستانه اعمال ۱ نشانه سیاه‌کردن هستید. متن، تصویر‌ها و ترسیم‌های زیر نشانه‌ها، و یادداشت‌ها و فیلد‌های فرمی که با آن‌ها هم‌پوشانی دارند، برای همیشه حذف می‌شوند. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. در آستانه اعمال {n} نشانه سیاه‌کردن هستید. متن، تصویر‌ها و ترسیم‌های زیر نشانه‌ها، و یادداشت‌ها و فیلد‌های فرمی که با آن‌ها هم‌پوشانی دارند، برای همیشه حذف می‌شوند. + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. سند را پس از آن ذخیره کنید: ذخیره‌سازی کل فایل را باز‌نویسی می‌کند تا هیچ اثری از محتوای حذف‌شده در آن نماند. + Select the items to remove from this document. مواردی را برای حذف از این سند انتخاب کنید. + Metadata فراداده + File attachments پیوست‌های فایل + Comments and markups یادداشت‌ها و علامت‌گذاری‌ها + Hidden text متن پنهان + Hidden layers لایه‌های پنهان + Links, actions and JavaScripts پیوند‌ها، کنش‌ها و JavaScriptها + Private data of other applications داده‌های خصوصی سایر برنامه‌ها + None found چیزی یافت نشد + Form fields are flattened: their values stay visible. Saving rewrites the whole file. فیلد‌های فرم تخت می‌شوند: مقادیرشان نمایان می‌مانند. ذخیره‌سازی کل فایل را باز‌نویسی می‌کند. + Remove حذف + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. پاک‌سازی اطلاعات پنهان را از سند حذف می‌کند: فراداده، پیوست‌های فایل، یادداشت‌ها، فیلد‌های فرم (تخت‌شده)، متن و لایه‌های پنهان، نشانک‌ها، پیوند‌ها، کنش‌ها و اسکریپت‌ها، و داده‌های خصوصی برنامه‌ها. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. سند را پس از آن ذخیره کنید؛ ذخیره‌سازی کل فایل را باز‌نویسی می‌کند تا هیچ چیز حذف‌شده در آن نماند. + Printer: چاپگر: + Save as PDF ذخیره به‌صورت PDF + {name} (default) {name} (پیش‌فرض) + Copies: نسخه‌ها: + Collate مرتب‌سازی + Print in grayscale چاپ در مقیاس خاکستری + Two-sided: دورو: + Off خاموش + Flip on long edge چرخش روی لبه بلند + Flip on short edge چرخش روی لبه کوتاه + Printer default پیش‌فرض چاپگر + Paper: کاغذ: + Pages to Print صفحه‌ها برای چاپ + More options: گزینه‌های بیشتر: + All pages in range تمام صفحه‌ها در بازه + Odd pages only فقط صفحه‌های فرد + Even pages only فقط صفحه‌های زوج + Reverse pages معکوس‌کردن صفحه‌ها + Page Sizing & Handling اندازه و مدیریت صفحه + Size اندازه + Poster پوستر + Multiple چند‌گانه + Booklet دفترچه + Fit جا دادن + Shrink oversized pages کوچک‌کردن صفحه‌های بزرگ‌تر از حد + Custom scale: مقیاس سفارشی: + Tile scale: مقیاس کاشی: + Overlap: هم‌پوشانی: + Cut marks نشانه‌های برش + Pages per sheet: صفحه در هر برگ: + Page order: ترتیب صفحه‌ها: + Horizontal افقی + Horizontal reversed افقی معکوس + Vertical عمودی + Vertical reversed عمودی معکوس + Print page border چاپ حاشیه صفحه + Auto-rotate pages چرخش خودکار صفحه‌ها + Booklet subset: زیرمجموعه دفترچه: + Both sides دو طرف + Front side only فقط روی + Back side only فقط پشت + Binding: صحافی: + Left چپ + Right راست + Orientation: جهت: + Auto portrait/landscape خودکار عمودی/افقی + Portrait عمودی + Landscape افقی + Comments & Forms یادداشت‌ها و فرم‌ها + Document سند + Document and markups سند و علامت‌گذاری‌ها + Document and stamps سند و مهر‌ها + Form fields only فقط فیلد‌های فرم + Previous sheet برگ قبلی + Next sheet برگ بعدی + No sheets بدون برگ + Sheet {s} of {n} برگ {s} از {n} + Removed 1 hidden item. Save to remove them from the file. ۱ مورد پنهان حذف شد. برای حذف آن‌ها از فایل ذخیره کنید. + Removed {n} hidden items. Save to remove them from the file. {n} مورد پنهان حذف شد. برای حذف آن‌ها از فایل ذخیره کنید. + Document sanitized. Save to finish: saving rewrites the whole file. سند پاک‌سازی شد. برای پایان ذخیره کنید: ذخیره‌سازی کل فایل را باز‌نویسی می‌کند. + Applied 1 redaction mark. Save to remove the content from the file. ۱ نشانه سیاه‌کردن اعمال شد. برای حذف محتوا از فایل ذخیره کنید. + Applied {n} redaction marks. Save to remove the content from the file. {n} نشانه سیاه‌کردن اعمال شد. برای حذف محتوا از فایل ذخیره کنید. + JavaScript Console کنسول JavaScript + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript خاموش است (ترجیحات ▸ JavaScript). + Output appears here. خروجی اینجا نمایش داده می‌شود. + Script Name: نام اسکریپت: + This document has no document-level scripts. این سند اسکریپت سطح سند ندارد. + No form fields were detected هیچ فیلد فرمی شناسایی نشد + Detected 1 form field ۱ فیلد فرم شناسایی شد + Detected {n} form fields {n} فیلد فرم شناسایی شد + {field}: {e} {field}: {e} + Current page صفحه جاری + Selected pages صفحه‌های انتخاب‌شده + Document language زبان سند + Output خروجی + Searchable Image (Exact) تصویر قابل‌جستوجو (دقیق) + Adds invisible text over each word; the page image is not changed متن نامرئی روی هر واژه می‌افزاید؛ تصویر صفحه تغییر نمی‌کند + Downsample to کاهش نمونهبرداری به + Also recognize pages that already have text همچنین صفحه‌هایی که از قبل متن دارند را تشخیص بده + Pictures on those pages are recognized too; words already in the page's text are not added again تصویر‌های آن صفحه‌ها نیز تشخیص داده می‌شوند؛ واژه‌هایی که از قبل در متن صفحه هستند دوباره افزوده نمی‌شوند + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. تشخیص متن نصب نیست: فایل‌های مدل آن وجود ندارند. PdfCraft را دوباره نصب کنید، یا PDFCRAFT_MODELS را به پوشه ای که آن‌ها را دارد تنظیم کنید. + Settings تنظیمات + Optional اختیاری + Planned · {m} برنامه‌ریزی‌شده · {m} + Planned for milestone {m} — open to see what it will include برنامه‌ریزی‌شده برای نقطهعطف {m} — باز کنید تا ببینید چه چیزی را شامل می‌شود + Coming in milestone {m}. Items marked Ready work today. در نقطهعطف {m} می‌آید. موارد علامتخورده به‌عنوان آماده امروز کار می‌کنند. + 1 mark ۱ نشانه + {n} marks {n} نشانه + Dynamic پویا + Sign Here امضا اینجا + Standard Business تجاری استاندارد + Choose a stamp, then click on the page to place it. مهری انتخاب کنید، سپس برای قرار دادن آن روی صفحه کلیک کنید. + Note یادداشت + Polyline چند‌خطی + Attachment پیوست + Callout فراخوان + Typewriter ماشین تحریر + Checked علامتخورده + Unchecked بدون علامت + Marked علامت‌گذاری‌شده + Locked قفل‌شده + Search comments جستوجوی یادداشت‌ها + Add a comment افزودن یادداشت + No comments yet. هنوز یادداشتی نیست. + Pick a tool in the quick bar, or type above. ابزاری در نوار سریع انتخاب کنید، یا در بالا تایپ کنید. + No comments match. هیچ یادداشتی تطابق ندارد. + Unknown author نویسنده ناشناس + No colour بدون رنگ + Remove checkmark حذف علامت تیک + Mark with checkmark علامت‌گذاری با تیک + Reply پاسخ + Add a reply افزودن پاسخ + Post ارسال + Page {label} صفحه {label} + {kind} — click to show it on the page {kind} — برای نمایش آن روی صفحه کلیک کنید + Set status تعیین وضعیت + Copy text رو‌نویسی متن + Properties… ویژگی‌ها… + Make Current Properties Default پیش‌فرض‌کردن ویژگی‌های جاری + Make Properties Default پیش‌فرض‌کردن ویژگی‌ها + More options گزینه‌های بیشتر + Sort by مرتب‌سازی بر اساس + Author نویسنده + Type نوع + Colour رنگ + Show all comments نمایش همه یادداشت‌ها + Create PDF comment summary… ساخت خلاصه یادداشت‌های PDF… + Export all to data file… برونبری همه به فایل داده… + Filter comments پالایش یادداشت‌ها + Status وضعیت + Accepted پذیرفته‌شده + Rejected رد‌شده + Cancelled لغو‌شده + Completed تکمیل‌شده + Show all نمایش همه + Yellow زرد + Orange نارنجی + Red قرمز + Pink صورتی + Purple بنفش + Blue آبی + Light blue آبی روشن + Green سبز + Gray خاکستری + Black سیاه + Bookmark options گزینه‌های نشانک + Expand all bookmarks گشادن همه نشانک‌ها + Expand top-level bookmarks گشادن نشانک‌های سطح اول + Collapse all bookmarks بستن همه نشانک‌ها + New bookmark ({key}) نشانک جدید ({key}) + This document has no bookmarks. این سند نشانک ندارد. + This document has no layers. این سند لایه ندارد. + This document has no attachments. این سند پیوست ندارد. + Hide layer پنهان‌کردن لایه + Layer options گزینه‌های لایه + Reset to Initial Visibility باز‌نشانی به نمایانبودن اولیه + Show layer نمایش لایه + Set layer visibility تعیین نمایانبودن لایه + on page {label} در صفحه {label} + Save attachment… ذخیره پیوست… + Open in a new tab باز کردن در برگه جدید + This document has no pages. این سند صفحه ندارد. + Find text یافتن متن + No matches بدون تطابق + Searching… {s}/{p} جستوجو… {s}/{p} + {c} of {n}{more} {c} از {n}{more} + Previous ({key}) قبلی ({key}) + Next ({key}) بعدی ({key}) + Find options گزینه‌های یافتن + Whole words only فقط واژه‌های کامل + Case-sensitive حساس به بزرگی و کوچکی حروف + Close (Esc) بستن (Esc) + Align تراز‌بندی + Top بالا + Bottom پایین + Center horizontally تراز افقی در وسط + Center vertically تراز عمودی در وسط + Distribute توزیع + Horizontally افقی + Vertically عمودی + Set Fields to Same Size هم‌اندازه‌کردن فیلد‌ها + Height ارتفاع + Width پهنا + Both هر دو + Duplicate… تکثیر… + All pages همه صفحه‌ها + Odd pages صفحه‌های فرد + Even pages صفحه‌های زوج + Landscape pages صفحه‌های افقی + Portrait pages صفحه‌های عمودی + Move earlier جابه‌جایی به قبل + Move later جابه‌جایی به بعد + Back to the document بازگشت به سند + Protect Using Password محافظت با گذرواژه + Requires user to enter a password for: نیاز‌مند وارد کردن گذرواژه توسط کاربر برای: + Viewing نمایش + Editing ویرایش + Type Password گذرواژه را تایپ کنید + Re-type Password گذرواژه را دوباره تایپ کنید + Strength: {s} قدرت: {s} + Strong قوی + Medium متوسط + Weak ضعیف + Advanced Options {chevron} گزینه‌های پیشرفته {chevron} + Compatibility سازگاری + Acrobat X and later (256-bit AES) Acrobat X و بعد از آن (AES ۲۵۶-بیتی) + Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 و بعد از آن (AES ۱۲۸-بیتی) + Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 و بعد از آن (RC4 ۱۲۸-بیتی) + Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 و بعد از آن (RC4 ۴۰-بیتی) + Encrypt رمز‌گذاری + All document contents همه محتوای سند + All document contents except metadata همه محتوای سند بهجز فراداده + Printing allowed چاپ مجاز + Low Resolution (150 dpi) تفکیک‌پذیری پایین (۱۵۰ dpi) + Changes allowed تغییرات مجاز + Inserting, deleting, and rotating pages افزودن، حذف و چرخش صفحه‌ها + Filling in form fields and signing existing signature fields پر کردن فیلد‌های فرم و امضای فیلد‌های امضای موجود + Commenting, filling in form fields, and signing existing signature fields یادداشت‌گذاری، پر کردن فیلد‌های فرم و امضای فیلد‌های امضای موجود + Any except extracting pages هر چیزی بهجز استخراج صفحه‌ها + Enable copying of text, images, and other content امکان کپی متن، تصویر‌ها و سایر محتوا + Enable text access for screen reader devices for the visually impaired امکان دسترسی متنی برای دستگاه‌های صفحهخوان برای افراد کمبینا + Type a password. گذرواژ‌های تایپ کنید. + The passwords don't match. گذرواژه‌ها مطابقت ندارند. + This compatibility level supports only plain ASCII passwords. این سطح سازگاری فقط از گذرواژه‌های ASCII ساده پشتیبانی می‌کند. + Anyone opening the document will need this password. It's applied when you save. هر کسی که سند را باز کند به این گذرواژه نیاز دارد. هنگام ذخیره اعمال می‌شود. + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. سند بدون گذرواژه باز می‌شود؛ این گذرواژه برای تغییر محدودیت‌ها لازم است. هنگام ذخیره اعمال می‌شود. + Recover unsaved documents? باز‌یابی سند‌های ذخیره‌نشده؟ + PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft به درستی بسته نشد. این سند‌ها تغییراتی داشتند که ذخیره خودکار شدهاند: + just now همین حالا + {n} min ago {n} دقیقه پیش + {n} h ago {n} ساعت پیش + · password-protected · محافظت‌شده با گذرواژه + Recover باز‌یابی + Discard دور انداختن + Version {v} نگارش {v} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. یک برنامه PDF متن باز و clean-room نوشته‌شده با Rust. MIT OR Apache-2.0. + Part of بخشی از + Summarize Options گزینه‌های خلاصه‌سازی + Choose a layout: یک چیدمان انتخاب کنید: + Comments only فقط یادداشت‌ها + Sort comments by: مرتب‌سازی یادداشت‌ها بر اساس: + Create PDF Comment Summary ایجاد خلاصه یادداشت‌های PDF + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. باز‌گرداندن به نگارش ذخیره‌شده پیشین «{name}»؟ تغییرات پس از آن دیگر قابل واگردانی نیست. + Save over the changed file? روی فایل تغییریافته ذخیره شود؟ + “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. «{name}» پس از باز شدن یا آخرین ذخیره، روی دیسک تغییر کرده است. ذخیره کردن اکنون آن نگارش جدید‌تر را با این یکی جایگزین می‌کند. + Save anyway به هرحال ذخیره کن + Number pages شماره‌گذاری صفحه‌ها + Style سبک + None (prefix only) هیچکدام (فقط پیشوند) + Prefix پیشوند + Start شروع + Labels: {preview}. Later pages keep their labels. برچسب‌ها: {preview}. صفحه‌های بعدی برچسب‌های خود را حفظ می‌کنند. + Even and Odd Pages صفحه‌های زوج و فرد + Even Pages Only فقط صفحه‌های زوج + Odd Pages Only فقط صفحه‌های فرد + Landscape and Portrait Pages صفحه‌های افقی و عمودی + Landscape Pages صفحه‌های افقی + Portrait Pages صفحه‌های عمودی + Duplicate Field تکثیر فیلد + Duplicate "{name}" onto: تکثیر «{name}» روی: + 1 page selected. ۱ صفحه انتخاب شده است. + {n} pages selected. {n} صفحه انتخاب شده است. + Delete pages after extracting حذف صفحه‌ها پس از استخراج + Extract pages as separate files استخراج صفحه‌ها به‌عنوان فایل‌های جداگانه + Rotate Pages چرخش صفحه‌ها + Direction: جهت: + Clockwise 90 degrees ۹۰ درجه در جهت عقربه‌های ساعت + Counterclockwise 90 degrees ۹۰ درجه در خلاف جهت عقربه‌های ساعت + 180 degrees ۱۸۰ درجه + Pages: صفحه‌ها: + All همه + Selection انتخاب + From از + Rotate: چرخش: + Repairs تعمیرات + Repair log گزارش تعمیرات + Split document تقسیم سند + Number of pages تعداد صفحه‌ها + Pages per file صفحه‌ها در هر فایل + File size اندازه فایل + At most حداکثر + Top-level bookmarks ({n}) نشانک‌های سطح بالا ({n}) + Before each selected page (select pages in Organize) پیش از هر صفحه انتخاب‌شده (صفحه‌ها را در سازمان‌دهی انتخاب کنید) + Creates 1 file from {n} pages. ۱ فایل از {n} صفحه می‌سازد. + Creates {f} files from {n} pages. {f} فایل از {n} صفحه می‌سازد. + Each file holds as many of the {n} pages as fit. هر فایل تا جایی که جا شود از {n} صفحه را در خود می‌گنجاند. + Replace Pages جای‌گزینی صفحه‌ها + Original: replace pages اصلی: جای‌گزینی صفحه‌ها + to تا + to {n} تا {n} + Replacement: pages of {name} جایگزین: صفحه‌های {name} + of {name} از {name} + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. فقط محتوای صفحه تغییر می‌کند: پیوند‌ها، یادداشت‌ها، فیلد‌های فرم و نشانک‌های صفحه‌های اصلی باقی می‌مانند. + The replacement file doesn't have that many pages. فایل جایگزین آن تعداد صفحه را ندارد. + Tagged PDF PDF برچسبدار + Document Properties ویژگی‌های سند + Description توضیحات + Initial View نمای اولیه + Security امنیت + Fonts قلم‌ها + Advanced پیشرفته + Title عنوان + Subject موضوع + Keywords کلیدواژه‌ها + Application برنامه + PDF producer تولید‌کننده PDF + Layout and Magnification چیدمان و بزرگنمایی + Navigation tab زبانه پیمایش + Page Only فقط صفحه + Bookmarks Panel and Page پنل نشانک‌ها و صفحه + Pages Panel and Page پنل صفحه‌ها و صفحه + Attachments Panel and Page پنل پیوست‌ها و صفحه + Layers Panel and Page پنل لایه‌ها و صفحه + Page layout چیدمان صفحه + Default پیش‌فرض + Single Page تک‌صفحه + Single Page Continuous تک‌صفحه پیوسته + Two-Up (Facing) دوصفحه (روبهرو) + Two-Up Continuous (Facing) دوصفحه پیوسته (روبهرو) + Two-Up (Cover Page) دوصفحه (صفحه جلد) + Two-Up Continuous (Cover Page) دوصفحه پیوسته (صفحه جلد) + Magnification بزرگنمایی + Actual Size اندازه واقعی + Fit Page تناسب با صفحه + Fit Width تناسب با عرض + Fit Height تناسب با ارتفاع + Fit Visible تناسب با محتوای قابل مشاهده + Open to page باز شدن روی صفحه + of {pages} از {pages} + Window Options گزینه‌های پنجره + Resize window to initial page تغییر اندازه پنجره به صفحه اولیه + Center window on screen وسط چین کردن پنجره روی صفحه نمایش + Open in Full Screen mode باز شدن در حالت تمام صفحه + Show: نمایش: + File Name نام فایل + Document Title عنوان سند + User Interface Options گزینه‌های رابط کاربری + Hide menu bar پنهان کردن نوار منو + Hide toolbars پنهان کردن نوار‌های ابزار + Hide window controls پنهان کردن کنترل‌های پنجره + Security method روش امنیت + No security بدون امنیت + Restrictions محدودیت‌ها + None — everything is allowed هیچکدام — همه‌چیز مجاز است + Password security امنیت گذرواژه + Encryption رمز‌گذاری + Opened with باز‌شده با + — (protection is applied when you save) — (محافظت هنگام ذخیره اعمال می‌شود) + Owner password (no restrictions apply) گذرواژه مالک (هیچ محدودیتی اعمال نمی‌شود) + User password گذرواژه کاربر + Change settings… تغییر تنظیمات… + Printing چاپ + Not allowed مجاز نیست + Allowed مجاز + High resolution تفکیک‌پذیری بالا + Low resolution تفکیک‌پذیری پایین + Changing the document تغییر سند + Document assembly مونتاژ سند + Content copying کپی محتوا + Content copying for accessibility کپی محتوا برای دسترس‌پذیری + Commenting یادداشت‌گذاری + Filling of form fields پر کردن فیلد‌های فرم + No fonts are referenced by the pages. هیچ قلمی توسط صفحه‌ها ارجاع داده نشده است. + PDF version نگارش PDF + Location مکان + Page size اندازه صفحه + Yes بله + No خیر + Revisions باز‌بینی‌ها + View revision {n} نمایش باز‌بینی {n} + Open the file as it was saved then باز کردن فایل همان‌گونه که آن زمان ذخیره شده بود + Binding صحافی + Left Edge لبه چپ + Right Edge لبه راست + Language زبان + Saved {place}: {before} → {after} ({pct}% smaller){detail} ذخیره‌شده {place}: {before} → {after} ({pct}% کوچک‌تر){detail} + This file is already as small as it can be made ({size}). No copy was saved. این فایل از قبل تا حد امکان کوچک است ({size}). هیچ نسخ‌های ذخیره نشد. + Saved {place}: {before} → {after} (not smaller){detail} ذخیره‌شده {place}: {before} → {after} (کوچک‌تر نشد){detail} + Couldn't save the image: {e} ذخیره تصویر ممکن نشد: {e} + Adding images arrives on the web with file pickers for images افزودن تصویر‌ها در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شود + Replacing images on page {p} arrives on the web with image pickers ({i}) جای‌گزینی تصویر‌ها در صفحه {p} در نسخه وب با انتخابگر تصویر ارائه می‌شود ({i}) + {name}: choosing images arrives on the web with file pickers for images {name}: انتخاب تصویر‌ها در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شود + Replacing images arrives on the web with image file pickers جای‌گزینی تصویر‌ها در نسخه وب با انتخابگر فایل تصویر ارائه می‌شود + Couldn't create a PDF: {e} ایجاد PDF ممکن نشد: {e} + The clipboard has no image or text بریدهدان هیچ تصویر یا متنی ندارد + On the web, open or drop an image to convert it در نسخه وب، تصویری را باز کنید یا رها کنید تا تبدیل شود + Couldn't optimize the file: {e} بهینه‌سازی فایل ممکن نشد: {e} + Couldn't write {path}: {e} نوشتن {path} ممکن نشد: {e} + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. فرم درخواست ارسال به {u} را دارد؛ PdfCraft داده‌های فرم را ارسال نمی‌کند. سند را ذخیره کنید تا ورودی‌هایتان حفظ شوند. + This PDF uses the unsupported {s} action. این PDF از کنش {s} پشتیبانی‌نشده استفاده می‌کند. + {name}: the {other} action isn't supported yet {name}: کنش {other} هنوز پشتیبانی نمی‌شود + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} فرم را به {url} ارسال می‌کند؛ PdfCraft داده‌های فرم را ارسال نمی‌کند. سند را ذخیره کنید تا ورودی‌هایتان حفظ شوند. + Export stopped: {e} برونبری متوقف شد: {e} + Exported 1 image ۱ تصویر برونبری شد + Exported {n} images {n} تصویر برونبری شد + ({s} not exported: {first}) ({s} برونبری نشد: {first}) + Exported the text of 1 page متن ۱ صفحه برونبری شد + Exported the text of {n} pages متن {n} صفحه برونبری شد + {msg} to {dir} {msg} به {dir} + Exporting… {d} of {t} در حال برونبری… {d} از {t} + Choose a folder for the exported files یک پوشه برای فایل‌های برونبری‌شده انتخاب کنید + New {tool} comments will look like this one یادداشت‌های جدید {tool} به این شکل خواهند بود + This document can't be checked: it couldn't be read for editing این سند بررسی نمی‌شود: برای ویرایش خوانده نشد + The checker found no problems in this document. بررسی‌کننده هیچ مشکلی در این سند پیدا نکرد. + The checker found 1 problem which may prevent the document from being fully accessible. بررسی‌کننده ۱ مشکل پیدا کرد که ممکن است مانع دسترس‌پذیری کامل سند شود. + The checker found {n} problems which may prevent the document from being fully accessible. بررسی‌کننده {n} مشکل پیدا کرد که ممکن است مانع دسترس‌پذیری کامل سند شوند. + The text is the same متن یکسان است + 1 difference found ۱ تفاوت پیدا شد + {n} differences found {n} تفاوت پیدا شد + Added 1 comment ۱ یادداشت افزوده شد + Added {n} comments {n} یادداشت افزوده شد + Choose a folder for the results یک پوشه برای نتایج انتخاب کنید + Files for {name} فایل‌های {name} + An action is already running یک کنش از قبل در حال اجراست + Running action… file {d} of {t} در حال اجرای کنش… فایل {d} از {t} + No unlinked web addresses were found in the text هیچ نشانی وب پیوندنشد‌های در متن پیدا نشد + Created 1 link ۱ پیوند ایجاد شد + Created {n} links {n} پیوند ایجاد شد + Text recognition is already running باز‌شناسی متن از قبل در حال اجراست + Optimizing… removing user data در حال بهینه‌سازی… حذف داده‌های کاربر + Optimizing… image {d} of {t} در حال بهینه‌سازی… تصویر {d} از {t} + Optimizing… cleaning up در حال بهینه‌سازی… پاک‌سازی + Optimizing… merging identical objects در حال بهینه‌سازی… ادغام اشیای یکسان + Optimizing… writing the copy در حال بهینه‌سازی… نوشتن نسخه + Cancelling… در حال لغو… + Optimization is already running بهینه‌سازی از قبل در حال اجراست + Optimization cancelled بهینه‌سازی لغو شد + Couldn't optimize the file بهینه‌سازی فایل ممکن نشد + Recognizing text… file {d} of {t} در حال باز‌شناسی متن… فایل {d} از {t} + Recognizing text… page {d} of {t} در حال باز‌شناسی متن… صفحه {d} از {t} + Recognized 1 word ۱ واژه باز‌شناسی شد + Recognized {n} words {n} واژه باز‌شناسی شد + {words} on {pages} {words} در {pages} + ; 1 page already had text ؛ ۱ صفحه از قبل متن داشت + ; {n} pages already had text ؛ {n} صفحه از قبل متن داشتند + Recognized {w} words in 1 file {w} واژه در ۱ فایل باز‌شناسی شد + Recognized {w} words in {n} files {w} واژه در {n} فایل باز‌شناسی شد + ; failed: {list} ؛ ناموفق: {list} + 1 file done ۱ فایل انجام شد + {n} files done {n} فایل انجام شد + The selected area has been copied ({w} × {h} pixels) ناحیه انتخاب‌شده کپی شد ({w} × {h} پیکسل) + Couldn't take the snapshot: {e} گرفتن تصویر لحظ‌های ممکن نشد: {e} + Couldn't recover {name}: {e} باز‌یابی {name} ممکن نشد: {e} + The document now conforms to {level}; save it to keep the changes سند اکنون با {level} منطبق است؛ برای حفظ تغییرات آن را ذخیره کنید + Fixed what could be fixed; 1 problem remains آنچه قابل رفع بود رفع شد؛ ۱ مشکل باقی مانده است + Fixed what could be fixed; {n} problems remain آنچه قابل رفع بود رفع شد؛ {n} مشکل باقی مانده است + Custom stamps arrive on the web with file pickers for images مهر‌های سفارشی در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شوند + {file} is too large for a stamp (at most {n} MB) {file} برای یک مهر بیش از حد بزرگ است (حداکثر {n} MB) + Sent to {printer} به {printer} فرستاده شد + Sent to {printer}: {msg} به {printer} فرستاده شد: {msg} + Looking for printers… در حال جستوجوی چاپگر‌ها… + Sending sheet {s} of {n} to {printer}… در حال فرستادن برگ {s} از {n} به {printer}… + No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. هیچ چاپ‌گری پیدا نشد. بررسی کنید که چاپگر نصب شده و صف چاپ در حال اجراست، سپس دوباره چاپ را باز کنید. + Reading the printer's settings… در حال خواندن تنظیمات چاپگر… + Sending to {printer}… در حال فرستادن به {printer}… + Choose a folder for the searchable files یک پوشه برای فایل‌های قابل جستوجو انتخاب کنید + Files are combined in this order. Leave Pages empty to take every page. فایل‌ها به این ترتیب ترکیب می‌شوند. صفحه‌ها را خالی بگذارید تا همه صفحه‌ها گرفته شوند. + Files are combined from top to bottom. Leave Pages empty to take every page. فایل‌ها از بالا به پایین ترکیب می‌شوند. صفحه‌ها را خالی بگذارید تا همه صفحه‌ها گرفته شوند. + Unlock… باز کردن قفل… + Select a password-protected file first نخست یک فایل محافظت‌شده با گذرواژه انتخاب کنید + Enter the permissions password to allow combining گذرواژه مجوز‌ها را وارد کنید تا ترکیب مجاز شود + Enter the password that opens it گذرواژ‌های را وارد کنید که آن را باز می‌کند + Unlock {name} باز کردن قفل {name} + Unlock {n} files باز کردن قفل {n} فایل + The same password is tried on each file. گذرواژه یکسان روی هر فایل آزموده می‌شود. + Hide password پنهان کردن گذرواژه + Show password نمایش گذرواژه + Wrong password گذرواژه نادرست + The password is only used to read the file; it isn't saved. گذرواژه فقط برای خواندن فایل استفاده می‌شود؛ ذخیره نمی‌شود. + Unlock باز کردن قفل + Opened, but its security settings don't allow combining. Enter the permissions password. باز شد، اما تنظیمات امنیتی آن ترکیب را مجاز نمی‌کند. گذرواژه مجوز‌ها را وارد کنید. + Unlocked: the combined file won't be password-protected قفل باز شد: فایل ترکیب‌شده محافظت‌شده با گذرواژه نخواهد بود + Unlocked {n} of {m} files قفل {n} از {m} فایل باز شد + Drop PDFs, images, text files or folders here, or choose them. فایل‌های PDF، تصویر‌ها، فایل‌های متنی یا پوشه‌ها را اینجا رها کنید، یا آن‌ها را انتخاب کنید. + Add folder… افزودن پوشه… + Add folder and subfolders… افزودن پوشه و زیرپوشه‌ها… + More ways to add files راه‌های بیشتر برای افزودن فایل + Every PDF, image and text file in the folder همه فایل‌های PDF، تصویر و متنی در پوشه + Every PDF, image and text file in the folder and the folders inside it همه فایل‌های PDF، تصویر و متنی در پوشه و پوشه‌های داخل آن + Add folder افزودن پوشه + Add folder and subfolders افزودن پوشه و زیرپوشه‌ها + No PDFs, images or text files found in {folder} هیچ فایل PDF، تصویر یا متنی در {folder} پیدا نشد + Added the first {n} files in {folder} نخستین {n} فایل در {folder} افزوده شد +comment menu Edit ویرایش + Undo {action} واگردانی {action} + Redo {action} ازنو انجام دادن {action} +signature pad Type تایپ +certificate Subject دارنده +action wizard Start شروع + Click to sort, drag to move the column. Drag its edge to resize it. برای مرتب‌سازی کلیک کنید، برای جابه‌جایی ستون آن را بکشید. برای تغییر انداز‌هاش لبه آن را بکشید. + Reset columns باز‌نشانی ستون‌ها + Remove {n} files حذف {n} فایل + {n} selected {n} انتخاب‌شده + sorted ascending مرتب‌شده صعودی + sorted descending مرتب‌شده نزولی + Protected: the combined file won't keep its security settings محافظت‌شده: فایل ترکیب‌شده تنظیمات امنیتی خود را حفظ نخواهد کرد + Add open documents افزودن سند‌های باز + All open documents همه سند‌های باز + No documents are open هیچ سندی باز نیست + Select a file first نخست یک فایل انتخاب کنید + 1 file ۱ فایل + {n} files {n} فایل + 1 file needs attention ۱ فایل نیاز به توجه دارد + {n} files need attention {n} فایل نیاز به توجه دارند + {name} can't be combined: {why} {name} ترکیب نمی‌شود: {why} + Check the pages of {name}: {why} صفحه‌های {name} را بررسی کنید: {why} + Add the files to combine فایل‌هایی را که می‌خواهید ترکیب کنید بی‌فزایید + File name نام فایل + Warnings هشدار‌ها + Drag to reorder برای مرتب‌سازی مجدد بکشید + {k} of {n} {k} از {n} + ? of {n} ؟ از {n} + Added more than once بیش از یک بار افزوده شده + yesterday دیروز + {n} days ago {n} روز پیش + No pages selected هیچ صفح‌های انتخاب نشده + Password-protected محافظت‌شده با گذرواژه + Its security settings don't allow copying pages تنظیمات امنیتی آن کپی صفحه‌ها را مجاز نمی‌کند + Can't be read: {e} خوانده نمی‌شود: {e} + Leave out files that can't be read? فایل‌هایی که خوانده نمی‌شوند کنار گذاشته شوند؟ + These files can't be read, so they won't be in the combined PDF: این فایل‌ها خوانده نمی‌شوند، بنابراین در PDF ترکیب‌شده نخواهند بود: + Combine the rest ترکیب بقیه + and {n} more و {n} مورد دیگر + Includes unsaved changes شامل تغییرات ذخیره‌نشده + Pages of {name} to combine, e.g. 1-3, 6 صفحه‌های {name} برای ترکیب، مثلاً ۱-۳، ۶ + Remove {name} حذف {name} + Add files… افزودن فایل‌ها… + Add at least two files دستکم دو فایل بی‌فزایید + Couldn't add {name}: {e} افزودن {name} ممکن نشد: {e} + Couldn't combine files: {e} ترکیب فایل‌ها ممکن نشد: {e} + Export comments برونبری یادداشت‌ها + Export form data برونبری داده‌های فرم + Comment and form data داده‌های یادداشت و فرم + Export برونبری + Select data files to merge فایل‌های داده برای ادغام را انتخاب کنید + Form data and PDF forms داده‌های فرم و فرم‌های PDF + Save the spreadsheet ذخیره صفحهگسترده + Save the accessibility report ذخیره گزارش دسترس‌پذیری + Save image ذخیره تصویر + Export certificate برونبری گواهی + Choose a folder for the extracted pages یک پوشه برای صفحه‌های استخراج‌شده انتخاب کنید + Choose a folder for the split files پوشه ای برای فایل‌های تقسیم‌شده انتخاب کنید + Couldn't use {name}: {e} استفاده از {name} ممکن نشد: {e} + Couldn't extract pages: {e} استخراج صفحه‌ها ممکن نشد: {e} + Couldn't write {name}: {e} نوشتن {name} ممکن نشد: {e} + the name is not a plain file name نام یک نام فایل ساده نیست + Wrote 1 file to {dir} ۱ فایل در {dir} نوشته شد + Wrote {n} files to {dir} {n} فایل در {dir} نوشته شد + No pages match those choices هیچ صفحه ای با آن گزینه‌ها مطابقت ندارد + Couldn't split: {e} تقسیم ممکن نشد: {e} + Couldn't summarize comments: {e} خلاصه‌کردن یادداشت‌ها ممکن نشد: {e} + Couldn't open the result: {e} باز کردن نتیجه ممکن نشد: {e} + Importing data arrives on the web with file pickers for data files ورود داده در نسخه وب با انتخابگر فایل برای فایل‌های داده ارائه می‌شود + Exported to {path} به {path} خروجی گرفته شد + Merging data files needs the desktop app ادغام فایل‌های داده نیاز‌مند برنامه رومیزی است + Merged 1 file into {path} ۱ فایل در {path} ادغام شد + Merged {n} files into {path} {n} فایل در {path} ادغام شد + Combined {n} files {n} فایل ترکیب شد + Extracted 1 page ۱ صفحه استخراج شد + Extracted {n} pages {n} صفحه استخراج شد + Created a comment summary خلاصه یادداشت‌ها ساخته شد + Couldn't open {name}: {e} باز کردن {name} ممکن نشد: {e} + Couldn't read {name}: {e} خواندن {name} ممکن نشد: {e} + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. حالت قابل حمل خاموش است: نوشتن در {folder} ممکن نیست ({error}). در عوض تنظیمات در پوشه کاربر شما نگهداری می‌شود. + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft بدون پردازنده گرافیکی رسم می‌کند، بنابراین ممکن است کند باشد. به‌روز‌رسانی درایور گرافیکی می‌تواند کمک کند. + Couldn't save: {e} ذخیره ممکن نشد: {e} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) دانلود پیوست‌ها در نسخه وب با M3.10 ارائه می‌شود ({n} بایت آماده) + `{command}` {when} `{command}` {when} + ships in milestone {m} در نقطهعطف {m} ارائه می‌شود + needs an AI provider (off by default) نیاز‌مند یک ارائهدهنده هوش مصنوعی است (به‌طور پیش‌فرض خاموش) + is available در دسترس است + is not available yet هنوز در دسترس نیست + {label} failed: {e} {label} ناموفق بود: {e} + Undid {label} {label} واگردانی شد + Redid {label} {label} ازنو انجام شد + A document needs at least one page: copy instead یک سند به حداقل یک صفحه نیاز دارد: در عوض رونوشت بگیرید + Cut {what} {what} بریده شد + Copied {what} {what} رونوشت شد + 1 page ۱ صفحه + {n} pages {n} صفحه + Copy or cut pages first ابتدا صفحه‌ها را رونوشت یا ببرید + Couldn't save {name}: {e} ذخیره {name} ممکن نشد: {e} + Saved {name} {name} ذخیره شد + Saved, but reopening failed: {e} ذخیره شد، اما باز‌گشایی مجدد ناموفق بود: {e} + Downloaded {name} {name} دانلود شد + Couldn't download {name}: {e} دانلود {name} ممکن نشد: {e} + Reverted to the last saved version به آخرین نسخه ذخیره‌شده باز‌گردانده شد + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. این کار نشد: یک خطای داخلی آن را متوقف کرد ({m}). اسناد شما تغییری نکرده اند. + Nothing to undo چیزی برای واگردانی نیست + Nothing to redo چیزی برای ازنو انجام دادن نیست + This document has no form fields you can fill in این سند هیچ فیلد فرمی ندارد که بتوانید پر کنید + This document has no comments to flatten این سند هیچ یادداشتی برای مسطح‌کردن ندارد + This document has no form fields to flatten این سند هیچ فیلد فرمی برای مسطح‌کردن ندارد + There are no redaction marks (mark text, areas or pages first) هیچ علامت سیاهکردنی وجود ندارد (ابتدا متن، ناحیه یا صفحه‌ها را علامت بزنید) + This document has no {kind} to change این سند هیچ {kind} برای تغییر ندارد + header or footer سرصفحه یا پاصفحه + watermark واترمارک + background پس‌زمینه + This document isn't password-protected این سند با گذرواژه محافظت نشده است + Enter permissions password… گذرواژه مجوز‌ها را وارد کنید… + Permissions password گذرواژه مجوز‌ها + “{name}” has security restrictions. Enter its permissions password to lift them. «{name}» محدودیت‌های امنیتی دارد. برای برداشتن آن‌ها گذرواژه مجوز‌هایش را وارد کنید. + Restrictions lifted محدودیت‌ها برداشته شد + Couldn't unlock {name}: {e} باز کردن قفل {name} ممکن نشد: {e} + This document has no restrictions to lift این سند هیچ محدودیتی برای برداشتن ندارد + The document's security settings don't allow this change تنظیمات امنیتی سند این تغییر را مجاز نمی‌داند + The document's security settings don't allow copying text and images تنظیمات امنیتی سند رونوشت متن و تصویر را مجاز نمی‌داند + The document's security settings don't allow printing تنظیمات امنیتی سند چاپ را مجاز نمی‌داند + Open a document first ابتدا یک سند باز کنید + Security will be removed when you save با ذخیره کردن، امنیت برداشته می‌شود + Click text or an image to edit it روی یک متن یا تصویر کلیک کنید تا ویرایشش کنید + Drag a rectangle to create a link; double-click a link to edit it برای ساختن پیوند یک مستطیل بکشید؛ برای ویرایش پیوند روی آن دوبار کلیک کنید + There are no redaction marks to apply هیچ علامت سیاه کردنی برای اعمال وجود ندارد + Drag to draw the area where the signature should appear. بکشید تا ناحیه ای که امضا باید در آن ظاهر شود رسم کنید. + Couldn't fit the visible content: {e} جا دادن محتوای قابل مشاهده ممکن نشد: {e} + Drag a rectangle around the area to copy دور ناحی‌های که می‌خواهید رونوشت بگیرید یک مستطیل بکشید + Click on the page to add a {tool}, or drag to set its size برای افزودن {tool} روی صفحه کلیک کنید، یا برای تعیین انداز‌هاش بکشید + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes برای برش صفحه، روی آن یک مستطیل بکشید؛ برای «تعیین کادر‌های صفحه» روی یک صفحه دوبار کلیک کنید + Keep tool selected: on نگه داشتن ابزار انتخاب‌شده: روشن + Keep tool selected نگه داشتن ابزار انتخاب‌شده + Opacity کدری + Line thickness ضخامت خط + What word or phrase would you like to search for? چه واژه یا عبارتی را می‌خواهید جستوجو کنید؟ + Searching… {s} of {p} pages در حال جستوجو… {s} از {p} صفحه + (searching… {s} of {p} pages) (در حال جستوجو… {s} از {p} صفحه) + 1 instance ۱ مورد + {n} instances {n} مورد + Go to this result رفتن به این نتیجه + Highlight برجسته‌سازی + Draw رسم + Polygon چند‌ضلعی + Connected lines خطوط متصل + Eraser پاککن + Sign امضا کردن + Certified by {by} گواهی‌شده توسط {by} + At least one signature is invalid. حداقل یک امضا نامعتبر است. + Signed and all signatures are valid. امضا شده و همه امضا‌ها معتبرند. + At least one signature has problems. حداقل یک امضا مشکل دارد. + This document is secured. Some changes are restricted by its security settings. این سند محافظت شده است. برخی تغییر‌ها توسط تنظیمات امنیتی آن محدود شدهاند. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. این یک فرم XFA پویاست که PdfCraft هنوز نمی‌تواند نمایش دهد. آنچه می‌بینید صفحه جانگهدار فایل است. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. این فرم همچنین داده XFA دارد که PdfCraft هنوز نمی‌خواند. می‌توانید فیلد‌هایش را پر کنید، اما Acrobat ممکن است به‌جای آن مقادیر XFA را نشان دهد. + This document contains {n} interactive form fields. این سند {n} فیلد فرم تعاملی دارد. + This file was damaged and has been repaired. Saving keeps the repaired version. این فایل آسیب دیده بود و ترمیم شده است. ذخیره کردن نسخه ترمیم‌شده را حفظ می‌کند. + Dismiss بستن + Hide field highlights پنهان‌کردن برجسته‌سازی فیلد‌ها + Highlight fields برجسته‌سازی فیلد‌ها + Security settings تنظیمات امنیت + Details جزئیات + Select (V) انتخاب (V) + Hand (H) دست (H) + Delete pages (Delete) حذف صفحه‌ها (Delete) + Insert a blank page after the selection درج یک صفحه خالی پس از انتخاب + Insert pages from a file… درج صفحه‌ها از یک فایل… + Extract pages to a new document استخراج صفحه‌ها به یک سند جدید + Split into files… تقسیم به فایل‌ها… + Select pages انتخاب صفحه‌ها + Cut برش + Copy رونوشت + Paste after چسباندن پس از + Rename تغییر نام + Set to current page ({n}) تعیین به‌عنوان صفحه فعلی ({n}) + Move up بالا بردن + Move down پایین بردن + Indent افزایش تورفتگی + Outdent کاهش تورفتگی + This document has no form fields. این سند هیچ فیلد فرمی ندارد. + Tab order: move a field with its arrows. ترتیب تب: یک فیلد را با فلش‌هایش جابهجا کنید. + Unplaced جاینگرفته + Has JavaScript actions (run in M6) دارای کنش‌های JavaScript است (در M6 اجرا می‌شوند) + Page صفحه + None هیچکدام + Delete selected fields حذف فیلد‌های انتخاب‌شده + Diagnostic details جزئیات تشخیصی + The operation failed. عملیات ناموفق بود. + no such document چنین سندی وجود ندارد + nothing to undo چیزی برای واگردانی نیست + nothing to redo چیزی برای ازنو انجام دادن نیست + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) این سند امضا شده است: باز‌نویسی آن امضا‌هایش را بی‌اعتبار می‌کند (در عوض آن را به‌صورت افزایشی ذخیره کنید) + the document is protected by a password سند با گذرواژه محافظت شده است + the password is incorrect گذرواژه نادرست است + the document has no page tree سند هیچ درخت صفح‌های ندارد + a document must keep at least one page یک سند باید حداقل یک صفحه را حفظ کند + a bookmark cannot be moved inside itself نشانک را نمی‌توان داخل خودش جابهجا کرد + the title must not be empty عنوان نباید خالی باشد + the document has no form fields سند هیچ فیلد فرمی ندارد + there are no redaction marks to apply هیچ علامت سیاهکردنی برای اعمال وجود ندارد + there are no pages to print هیچ صفح‌های برای چاپ وجود ندارد + the document has no tags سند هیچ برچسبی ندارد + {0}: {1} {0}: {1} + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) مدل‌های تشخیص متن نصب نشدهاند (PdfCraft را دوباره نصب کنید، یا PDFCRAFT_MODELS را روی پوش‌های که آن‌ها را نگه می‌دارد تنظیم کنید) + the image is empty تصویر خالی است + this file isn't XFDF, FDF, XML or text form data این فایل داده فرم XFDF، FDF، XML یا متنی نیست + nothing in the file matches this document هیچچیز در فایل با این سند مطابقت ندارد + This page couldn't be displayed.\n{e} نمایش این صفحه ممکن نشد.\n{e} + Go to page {p} رفتن به صفحه {p} + {s} action کنش {s} + Page {p} of {n} صفحه {p} از {n} + 1 page selected ۱ صفحه انتخاب شد + {n} pages selected {n} صفحه انتخاب شد + {name} — unsaved changes {name} — تغییر‌های ذخیره‌نشده + {kind} field فیلد {kind} + Select a file for the stamp یک فایل برای مهر انتخاب کنید + JavaScript, e.g. getField("total").value JavaScript، مثلاً getField("total").value + Font: Helvetica قلم: Helvetica + e.g. en-US مثلاً fa-IR + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) رندر: hayro (bootstrap) · رابط کاربری: egui · آیکون‌ها: Lucide (ISC) · قلم‌ها: Inter, JetBrains Mono, Dancing Script (OFL) + CheckBox کادر انتخاب + Radio دکمه رادیویی + PushButton دکمه فشاری + Combo فهرست باز‌شو + List فهرست + Unknown ناشناخته + The document has no XMP metadata سند هیچ فراداده XMP ندارد + The title in the metadata and the document information differ عنوان در فراداده و اطلاعات سند متفاوتاند + The document is encrypted سند رمز‌گذاری شده است + Device colour is used, but there is no PDF/A output intent از رنگ دستگاه استفاده می‌شود، اما هیچ هدف خروجی PDF/A وجود ندارد + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) از DeviceCMYK استفاده می‌شود: به یک هدف خروجی CMYK نیاز دارد (به‌طور خودکار افزوده نمی‌شود) + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile از DeviceCMYK استفاده می‌شود، اما هدف خروجی PDF/A یک نمایه CMYK نیست + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile از DeviceRGB استفاده می‌شود، اما هدف خروجی PDF/A یک نمایه RGB نیست + The document has additional actions (/AA) سند کنش‌های افزودنی دارد (/AA) + The document has document-level JavaScript سند JavaScript در سطح سند دارد + The form asks viewers to make appearances (NeedAppearances) فرم از نمایشگر‌ها می‌خواهد ظاهر‌ها را بسازند (NeedAppearances) + A form field has additional actions یک فیلد فرم کنش‌های افزودنی دارد + A stream uses LZW compression یک جریان از فشرده‌سازی LZW استفاده می‌کند + A stream refers to an external file یک جریان به یک فایل خارجی ارجاع می‌دهد + An image asks to be interpolated یک تصویر درخواست درون‌یابی دارد + An image has alternates or OPI information یک تصویر جانشین‌ها یا اطلاعات OPI دارد + PostScript XObjects aren't allowed XObjectهای PostScript مجاز نیستند + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) فایل‌های جا‌سازی‌شده خودشان باید فایل PDF/A باشند (برای سایرین از PDF/A-3 استفاده کنید) + An embedded file has no AFRelationship یک فایل جا‌سازی‌شده AFRelationship ندارد + Select Icon انتخاب آیکون + Choose images انتخاب تصویر‌ها + {dpi} pixels/inch {dpi} پیکسل بر اینچ + Type your {what}. {what} خود را تایپ کنید. + Draw your {what} below. {what} خود را در پایین رسم کنید. + Your name نام شما + Images and text (converted to PDF) تصویر‌ها و متن (تبدیل‌شده به PDF) + Images and text تصویر‌ها و متن + Check Box Style: سبک کادر انتخاب: + Button Style: سبک دکمه: + Check تیک + Circle دایره + Diamond لوزی + Square مربع + Star ستاره + {month} {y} {month} {y} + Change {key} تغییر {key} + Insert pages from {name} درج صفحه‌ها از {name} + Insert pages from {name} — 1 field renamed درج صفحه‌ها از {name} — ۱ فیلد تغییر نام یافت + Insert pages from {name} — fields renamed درج صفحه‌ها از {name} — فیلد‌ها تغییر نام یافتند + Fill in {name} پر کردن {name} + Set the image of {name} تعیین تصویر {name} + Edit script of {name} ویرایش اسکریپت {name} + Import {name} درون‌ریزی {name} + Add bookmark افزودن نشانک + Rename bookmark تغییر نام نشانک + Delete bookmark حذف نشانک + Move bookmark جابه‌جایی نشانک + Set bookmark destination تعیین مقصد نشانک + Add stamp افزودن مهر + Delete comment حذف یادداشت + Edit comment ویرایش یادداشت + Erase پاک کردن + Lock comment قفل کردن یادداشت + Unlock comment باز کردن قفل یادداشت + Move comment جابه‌جایی یادداشت + Resize comment تغییر اندازه یادداشت + Change comment properties تغییر ویژگی‌های یادداشت + Add field افزودن فیلد + Change field properties تغییر ویژگی‌های فیلد + Delete field حذف فیلد + Duplicate field تکثیر فیلد + Set tab order تعیین ترتیب تب + Set calculation order تعیین ترتیب محاسبه + Change initial view تغییر نمای اولیه + Set alternate text تعیین متن جانشین + Mark figure as decorative علامت‌گذاری تصویر به‌عنوان تزئینی + Run JavaScript اجرای JavaScript + Delete document JavaScript حذف JavaScript سند + Edit document JavaScript ویرایش JavaScript سند + Edit text ویرایش متن + Load document font بار‌گذاری قلم سند + Move image جابه‌جایی تصویر + Rotate image چرخاندن تصویر + Flip image وارونه کردن تصویر + Delete image حذف تصویر + Add header & footer افزودن سرصفحه و پاصفحه + Update header & footer به‌روز‌رسانی سرصفحه و پاصفحه + Update watermark به‌روز‌رسانی واترمارک + Add background افزودن پس‌زمینه + Update background به‌روز‌رسانی پس‌زمینه + Add image افزودن تصویر + Edit content ویرایش محتوا + Delete content حذف محتوا + Remove redaction marks برداشتن علامت‌های سیاه‌کردن + Add link افزودن پیوند + Change link properties تغییر ویژگی‌های پیوند + Delete link حذف پیوند + Create link ساختن پیوند + Flatten مسطح‌سازی + Flatten Fill & Sign مسطح‌سازی پر کردن و امضا + Add sticky note افزودن یادداشت چسبان + Add highlight افزودن برجسته‌سازی + Add underline افزودن زیرخط + Add strikethrough افزودن خط خوردگی + Add squiggly underline افزودن زیرخط موجدار + Add rectangle افزودن مستطیل + Add oval افزودن بیضی + Add arrow افزودن پیکان + Add line افزودن خط + Add drawing افزودن ترسیم + Add text box افزودن جعبه متن + Add checkmark افزودن تیک + Add cross افزودن ضربدر + Add dot افزودن نقطه + Add signature افزودن امضا + Add redaction mark افزودن علامت سیاه‌کردن + Add cloud افزودن ابر + Add polygon افزودن چند‌ضلعی + Add connected lines افزودن خطوط متصل + Add callout افزودن فراخوان + Add inserted text افزودن متن درج‌شده + Add file attachment افزودن پیوست فایل + Set status Accepted تعیین وضعیت پذیرفته‌شده + Set status Rejected تعیین وضعیت رد‌شده + Set status Cancelled تعیین وضعیت لغو‌شده + Set status Completed تعیین وضعیت تکمیل‌شده + Set status None تعیین وضعیت هیچکدام + Set status Unmarked تعیین وضعیت علامتنخورده + Save as PDF/A-1b ذخیره به‌عنوان PDF/A-1b + Save as PDF/A-2b ذخیره به‌عنوان PDF/A-2b + Save as PDF/A-3b ذخیره به‌عنوان PDF/A-3b + Fill & Sign: change signature پر کردن و امضا: تغییر امضا + Fill & Sign: change initials پر کردن و امضا: تغییر حروف اول + Fill & Sign: remove saved signature پر کردن و امضا: برداشتن امضای ذخیره‌شده + Fill & Sign: remove saved initials پر کردن و امضا: برداشتن حروف اول ذخیره‌شده + Change signature تغییر امضا + Change initials تغییر حروف اول + Measure distance اندازه‌گیری فاصله + Measure perimeter اندازه‌گیری محیط + Measure area اندازه‌گیری مساحت + Set measurement scale تعیین مقیاس اندازه‌گیری + Measurement information اطلاعات اندازه‌گیری + Measurement snapping چسبیدن اندازه‌گیری + Export measurements as CSV خروجی اندازه‌گیری‌ها به‌صورت CSV + Distance فاصله + Perimeter محیط + Area مساحت + Drawing scale مقیاس ترسیم + Scale مقیاس + Common scales مقیاس‌های رایج + Enter a scale such as 1:50. مقیاسی مانند ۱:۵۰ وارد کنید. + Applying a scale switches units to m / mm. اعمال مقیاس، واحد‌ها را به m / mm تغییر می‌دهد. + Snapping چسبیدن + Export measurements خروجی اندازه‌گیری‌ها + Open a PDF to measure it. برای اندازه‌گیری آن یک PDF باز کنید. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. روی رأس‌ها کلیک کنید. Enter یا دوبار کلیک یک محیط یا مساحت را تمام می‌کند. برای بستن یک مساحت روی نقطه اول کلیک کنید. + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift به ۴۵ درجه محدود می‌کند. Backspace یک رأس را برمی‌دارد. Escape لغو می‌کند. + Angle زاویه + Click the first point to begin. برای شروع روی نقطه اول کلیک کنید. + Snap to drawing چسبیدن به ترسیم + Endpoints نقاط انت‌هایی + Midpoints نقاط میانی + Intersections تقاطع‌ها + Paths مسیر‌ها + Sensitivity حساسیت + Unit واحد + Decimals اعشار + Calibrate from two points کالیبره کردن از دو نقطه + Entire page تمام صفحه + Viewport rectangle in page points (x, y, width, height) مستطیل نمایشگر در نقاط صفحه (x، y، عرض، ارتفاع) + Viewport نمایشگر + Apply scale اعمال مقیاس + Saved measurements اندازه‌گیری‌های ذخیره‌شده + Saved measurements. اندازه‌گیری‌های ذخیره‌شده. + Endpoint نقطه انت‌هایی + Midpoint نقطه میانی + Intersection تقاطع + Path مسیر + This drawing exceeds the snapping limit; only the first paths are used. این ترسیم از حد چسبیدن فرا‌تر می‌رود؛ فقط مسیر‌های اول استفاده می‌شوند. + Some page content couldn't be read; snapping uses the rest. بخشی از محتوای صفحه خوانده نشد؛ چسبیدن از بقیه استفاده می‌کند. + Unsupported measurements اندازه‌گیری‌های پشتیبانی‌نشده + Label برچسب + {n} measurements use an unsupported format and are left out. {n} اندازه‌گیری از یک قالب پشتیبانی‌نشده استفاده می‌کنند و کنار گذاشته می‌شوند. + This area has too many overlapping paths; intersection snapping is limited. این ناحیه مسیر‌های هم‌پوشان بیش از حد دارد؛ چسبیدن به تقاطع‌ها محدود است. + This measurement needs at least {n} points. این اندازه‌گیری به حداقل {n} نقطه نیاز دارد. + Change {what} تغییر {what} + Cut and stack برش و رویهمچینی + Default workspace mode حالت پیش‌فرض فضای کاری + Documents and view سند‌ها و نمایش + Open email app باز کردن برنامهٔ ایمیل + Open link باز کردن پیوند + Open this web page? این صفحهٔ وب باز شود؟ + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. اسکریپت‌ها در محیطی ایزوله و بدون دسترسی به فایل یا شبکه اجرا می‌شوند. اسکریپتی که درخواست باز کردن یک صفحهٔ وب را دارد، ابتدا به اجازهٔ شما نیاز دارد. با خاموش بودن JavaScript، توابع استاندارد قالب‌بندی، اعتبارسنجی و محاسبهٔ Acrobat همچنان کار می‌کنند. + Sign yourself خودتان امضا کنید + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. هنگام باز کردن PDFها استفاده می‌شود. حالت اجرا یا کنترل صریح برای آن نشست اولویت دارد. + Write this email? این ایمیل نوشته شود؟ + {who} in this document wants to open this address in your browser: {who} در این سند می‌خواهد این نشانی را در مرورگر شما باز کند: + {who} in this document wants to open your email app with this address: {who} در این سند می‌خواهد برنامهٔ ایمیل شما را با این نشانی باز کند: + This web address mixes letters from different alphabets, a common way to imitate another site's name. این نشانی وب حروفی از الفبا‌های مختلف را با هم می‌آمیزد؛ روشی رایج برای جعل نام سایت دیگر. + This web address uses letters from another alphabet, which can look like familiar ones. این نشانی وب از حروف الفبای دی‌گری استفاده می‌کند که می‌توانند شبیه حروف آشنا به نظر برسند. + Create PDF from images ساخت PDF از تصاویر + Selected image files: {count} فایل‌های تصویری انتخاب‌شده: {count} + Use image resolution استفاده از وضوح تصویر + Use each image's embedded DPI; use 72 DPI when it is absent. از DPI جا‌سازی‌شدهٔ هر تصویر استفاده کن؛ اگر نبود، ۷۲ DPI به‌کار ببر. + Use 72 DPI (one point per pixel) استفاده از ۷۲ DPI (یک نقطه بر هر پیکسل) + Use custom DPI استفاده از DPI سفارشی + DPI sets the printed page size without resampling the image. DPI اندازهٔ صفحهٔ چاپی را بدون نمونهبرداری مجدد تصویر تعیین می‌کند. + Create ساخت + Dynamic XFA form laid out from its template: {pages}, {fields}. فرم XFA پویا که از قالب آن چیده شده است: {pages}، {fields}. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. اسکریپت‌های این فرم خاموش شدهاند چون یکی از آن‌ها بیش از حد طول کشید. سند را دوباره باز کنید تا دوباره اجرا شوند. + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. فرم XFA: فیلد‌ها و داده‌های XFA آن هم‌گام نگه داشته می‌شوند، تا سایر نمایشگر‌ها آنچه را پر می‌کنید نشان دهند. +@plural {n} page|{n} pages {n} صفحه|{n} صفحه +@plural {n} field|{n} fields {n} فیلد|{n} فیلد +@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} نام فیلد با فایل‌های قبلی تداخل دارد و تغییر نام داده می‌شود: {list}|{n} نام فیلد با فایل‌های قبلی تداخل دارند و تغییر نام داده می‌شوند: {list} + Switch to two-page view first to show the cover page برای نمایش صفحهٔ جلد، ابتدا به نمایش دوصفح‌های بروید + Page display: {layout} نمایش صفحه: {layout} + Fit to width scrolling پیمایش متناسب با عرض + Fit one full page تناسب یک صفحهٔ کامل + Default page display نمایش صفحهٔ پیش‌فرض + Default zoom بزرگنمایی پیش‌فرض + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. وقتی PDF طرح‌بندی یا بزرگنمایی خاصی درخواست نمی‌کند استفاده می‌شود. پیمایش پیوسته هرگز بین صفحه‌ها نمی‌پرد. + Show نمایش + Sort مرتب‌سازی + Reverse the order معکوس کردن ترتیب + Grab bag متفرقه + Table جدول + Username نام کاربری + Display name نام نمایشی + Real name نام واقعی + Name (A–Z) نام (A–Z) + Merged PRs PRهای ادغام‌شده + PRs PRها + Commits کامیت‌ها + Lines added خطوط افزوده‌شده + +LOC +LOC + Lines deleted خطوط حذف‌شده + −LOC −LOC + Line delta دلتای خطوط + ΔLOC ΔLOC + Binary assets added دارایی‌های دودویی افزوده‌شده + +Bin +Bin + Binary assets removed دارایی‌های دودویی حذف‌شده + −Bin −Bin + First commit نخستین کامیت + First نخستین + Last commit آخرین کامیت + Last آخرین + No contributor data was built into this copy. هیچ داد‌های از هم‌کاران در این نسخه گنجانده نشده است. + No model credits were built into this copy. هیچ اعتبار مدلی در این نسخه گنجانده نشده است. + {n} contributors · {c} commits {n} هم‌کار · {c} کامیت + Company شرکت + Model مدل + % of all commits ٪ از همهٔ کامیت‌ها + Lines +/− خطوط +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR، {commits} کامیت، +{added} / −{deleted} خط (Δ {delta})، +{bin_added} / −{bin_deleted} دارایی دودویی، {first} – {last} + Reopen the files that were open when PdfCraft last closed باز‌گشایی فایل‌هایی که هنگام آخرین بسته شدن PdfCraft باز بودند + Restore the last view (page, zoom and page display) when a document is opened again باز‌یابی آخرین نما (صفحه، بزرگنمایی و نمایش صفحه) هنگام باز شدن دوبارهٔ سند + Add {what} افزودن {what} + Use {what} استفاده از {what} + Place saved {what} قرار دادن {what} ذخیره‌شده + Remove saved {what} حذف {what} ذخیره‌شده + A link یک پیوند + A button یک دکمه + A script یک اسکریپت + {who} in this document tried to open an address PdfCraft won't open: {e}. {who} در این سند تلاش کرد نشانیای را باز کند که PdfCraft باز نمی‌کند: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} در این سند تلاش کرد نشانیای را باز کند که PdfCraft باز نمی‌کند: {e}. از میان سند‌ها فقط پیوند‌های وب (http، https) و ایمیل (mailto) باز می‌شوند. + Another file dialog is still open. Finish with it first. یک پنجرهٔ گفتوگوی فایل دیگر هنوز باز است. ابتدا آن را تمام کنید. + Couldn't show the file picker. Please try again. انتخابگر فایل نمایش داده نشد. لطفاً دوباره تلاش کنید. + The document changed while you were choosing a file, so nothing was changed. سند در حین انتخاب فایل تغییر کرد، بنابراین چیزی تغییر نکرد. + The document was closed before it could be saved. سند پیش از آنکه ذخیره شود بسته شد. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. صفحه‌ها استخراج شدند اما حذف نشدند، زیرا سند در حین انتخاب پوشه تغییر کرد. + That didn't work: an internal error stopped it ({m}). انجام نشد: یک خطای داخلی آن را متوقف کرد ({m}). + The Windows store's digital IDs couldn't be listed: {e} شناسه‌های دیجیتال مخزن Windows فهرست نشدند: {e} + Align fields هم‌ترازی فیلد‌ها + Distribute fields توزیع فیلد‌ها + Match field sizes یکسان‌سازی اندازهٔ فیلد‌ها + Mark for redaction علامت‌گذاری برای سیاه‌کردن + Mark pages for redaction علامت‌گذاری صفحه‌ها برای سیاه‌کردن + Change document properties تغییر ویژگی‌های سند + Mark differences علامت‌گذاری تفاوت‌ها + Run form script اجرای اسکریپت فرم + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. محتوای اسکریپتی باید با صفحه کلید و فناوری‌های کمکی قابل استفاده باشد. اسکریپت‌های سند را بررسی کنید. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. سطر‌های جدول مستقیماً به جدول یا سر، بدنه یا پای آن تعلق دارند. برچسب‌های جدول را اصلاح کنید. + Basic constraints محدودیت‌های پایه + SHA-1 digest چکیده SHA-1 + SHA-256 digest چکیده SHA-256 + Squiggly موجدار + Home / End Home / End + H / Space (hold) H / فاصله (نگهداشتن) + Reload documents when their files change on disk بار‌گذاری مجدد سند‌ها وقتی فایل‌هایشان روی دیسک تغییر می‌کنند + Don't show again دیگر نشان نده + Show a notice when a document is secured نمایش اعلان وقتی سند محافظت شده است + Always use my default zoom and page layout همیشه از بزرگنمایی و طرح‌بندی صفحهٔ پیش‌فرض من استفاده کن + This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. این فایل روی دیسک تغییر کرد. «بار‌گذاری مجدد» نسخهٔ جدید را نشان می‌دهد و تغییرات ذخیره‌نشدهٔ شما را از دست می‌دهد؛ «ذخیره» آن را با این نسخه جایگزین می‌کند. + Reload بار‌گذاری مجدد + Couldn't reload {name}: {e} بار‌گذاری مجدد {name} ممکن نشد: {e} + Created a PDF from {n} files یک PDF از {n} فایل ساخته شد + PDF, images and text PDF، تصاویر و متن + Insert a file before page {label} درج یک فایل پیش از صفحهٔ {label} + Insert a file at the end درج یک فایل در پایان + Save pages ذخیرهٔ صفحه‌ها + Save these pages as one PDF ذخیرهٔ این صفحه‌ها به‌صورت یک PDF + No changes to save تغییری برای ذخیره نیست + Created a PDF from {n} files; left out: {names} یک PDF از {n} فایل ساخته شد؛ کنار گذاشته شد: {names} + Created a PDF from the first {n} files یک PDF از {n} فایل نخست ساخته شد + None of those files can be made into a PDF; use PDFs, images or .txt files هیچ یک از آن فایل‌ها را نمی‌توان به PDF تبدیل کرد؛ از فایل‌های PDF، تصویر یا .txt استفاده کنید + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. یک تصویر PNG یا JPEG انتخاب کنید (حداکثر ۴ MiB). شفافیت حفظ می‌شود. + Couldn't import the signature image: {e} تصویر امضا وارد نشد: {e} + Go to page… رفتن به صفحه… + About درباره + Contributors هم‌کاران + Models مدل‌ها + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. فقط اگر به این سند اعتماد دارید ادامه دهید. یک نشانی می‌تواند اطلاعاتی از سند را با خود حمل کند، مانند آنچه در فرم آن تایپ کردهاید. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. یکرو چاپ کنید. برگه‌ها را به ترتیب نگه دارید، روی علامت‌ها ببرید، سپس دسته‌ها را از چپ به راست و از بالا به پایین روی هم بگذارید. + For page sizes and margins. برای اندازه‌های صفحه و حاشیه‌ها. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. قلم‌های متن PDF نمی‌توانند «{c}» را بکشند ({code}). متن پشتیبانی‌شده انتخاب کنید. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. قلم‌های استاندارد نمی‌توانند «{c}» را بکشند ({code}). فعلاً فقط نویسه‌های اروپای غربی می‌توانند به‌عنوان متن افزوده شوند. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. قلم این فیلد نمی‌تواند «{c}» را بکشد ({code})، بنابراین PdfCraft به‌جای آن ? نشان می‌دهد. مقدار ذخیره می‌شود و سایر نمایشگر‌های PDF فیلد را از آن باز‌ترسیم می‌کنند. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. قلم‌هایی که PdfCraft با آن‌ها می‌نویسد نمی‌توانند «{c}» را بکشند ({code})، بنابراین در همهٔ نمایشگر‌های PDF به‌صورت ? نمایش داده می‌شود. متن ذخیره می‌شود. + Open arrow پیکان باز + Closed arrow پیکان بسته + Butt انت‌های صاف + Reverse open arrow پیکان باز معکوس + Reverse closed arrow پیکان بستهٔ معکوس + Slash اسلش + Larger pages صفحه‌های بزرگ‌تر + Smaller pages صفحه‌های کوچک‌تر + Reset page size باز‌نشانی اندازهٔ صفحه + Date format قالب تاریخ + Fill & Sign dates تاریخ‌های Fill & Sign + Pattern الگو + Today: {date} امروز: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy یا yy سال · m یا mm ماه · mmm یا mmmm نام ماه · d یا dd روز · ddd یا dddd روز هفته. H، h، M، s و t حروف زمان هستند و نمی‌توانند بهتن‌هایی استفاده شوند: برای نمایش حرف به همان صورت، {escape} را پیش از آن بگذارید. + Same as interface ({language}) یکسان با رابط کاربری ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} هنوز نمی‌تواند در PDF نوشته شود: متن Fill & Sign فقط اروپای غربی است. قالبی عددی مانند dd.mm.yyyy انتخاب کنید. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. ترجیحات درایور چاپگر را باز می‌کند. آن‌ها برای هر چاپ از این رایانه ذخیره می‌شوند؛ تعداد نسخه، دورو، مقیاس خاکستری و کاغذ انتخاب‌شده در اینجا همچنان اعمال می‌شوند. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. تنظیمات خود درایور چاپگر برای این چاپ: سینی کاغذ، نوع کاغذ، تکمیل و موارد دیگر. + Couldn't open the printer's preferences: {e} ترجیحات چاپگر باز نشد: {e} + Printer properties ویژگی‌های چاپگر + Reset to printer defaults باز‌نشانی به پیش‌فرض‌های چاپگر + This printer offers no settings of its own. این چاپگر تنظیمات اختصاصی خود را ارائه نمی‌دهد. + Scroll tabs left پیمایش زبانه‌ها به چپ + Scroll tabs right پیمایش زبانه‌ها به راست + Shared Field Properties ویژگی‌های مشترک فیلد‌ها + {n} fields selected {n} فیلد انتخاب‌شده + Mixed متفاوت + Custom font قلم سفارشی + Only checked properties will change. Mixed means the selected fields have different values. فقط ویژگی‌های علامتخورده تغییر می‌کنند. «متفاوت» یعنی فیلد‌های انتخاب‌شده مقادیر متفاوتی دارند. + Shift- or Ctrl/Command-click to select several fields. برای انتخاب چند فیلد، با Shift یا Ctrl/Command کلیک کنید. + Font size 0 uses automatic sizing. اندازهٔ قلم ۰ از اندازه‌گذاری خودکار استفاده می‌کند. + Character limit: محدودیت نویسه: + Character limit 0 means no limit. A comb of characters needs a limit. محدودیت نویسهٔ ۰ یعنی بدون محدودیت. شانهٔ نویسه‌ها به محدودیت نیاز دارد. + To change locked fields, also set Locked to No. برای تغییر فیلد‌های قفل‌شده، «قفل‌شده» را هم روی «خیر» بگذارید. + Comb of characters شانهٔ نویسه‌ها + The document changed while you were editing field properties. سند در حین ویرایش ویژگی‌های فیلد تغییر کرد. + The document is no longer open. سند دیگر باز نیست. + The field properties dialog is no longer open. پنجرهٔ ویژگی‌های فیلد دیگر باز نیست. + The field properties could not be changed. ویژگی‌های فیلد تغییر نکردند. + The selected fields could not be found. Select them again. فیلد‌های انتخاب‌شده یافت نشدند. دوباره آن‌ها را انتخاب کنید. + The field {name} no longer exists. فیلد {name} دیگر وجود ندارد. + A selected field has no widget on any page, so the fields can't be edited together. یک فیلد انتخاب‌شده در هیچ صفحه ای ویجت ندارد، بنابراین فیلد‌ها را نمی‌توان با هم ویرایش کرد. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} در {dpi} dpi، بزرگ‌ترین انداز‌های که می‌توانند در آن کشیده شوند) + The security settings don't allow screen readers to read the text تنظیمات امنیتی اجازه نمی‌دهند صفحه خوان‌ها متن را بخوانند + No page has text: the document appears to be images of pages هیچ صفحه ای متن ندارد: به نظر می‌رسد سند تصویر‌هایی از صفحه‌ها باشد + The document has no tags (structure tree) سند برچسبی ندارد (درخت ساختار) + The document isn't marked as tagged (MarkInfo Marked) سند به‌عنوان برچسبدار علامت‌گذاری نشده است (MarkInfo Marked) + No document language is set زبان سند تعیین نشده است + The document has no title سند عنوانی ندارد + The window shows the file name, not the title (Initial View > Show) پنجره نام فایل را نشان می‌دهد، نه عنوان را (نمای اولیه > نمایش) + {n} pages and no bookmarks {n} صفحه و بدون نشانک + The document isn't tagged, so none of its content is سند برچسبدار نیست، بنابراین هیچ یک از محتوای آن نیز برچسب ندارد + Page {p}: {n} untagged content item صفحهٔ {p}: {n} مورد محتوای بدون برچسب + Page {p}: {n} untagged content items صفحهٔ {p}: {n} مورد محتوای بدون برچسب + Form field on page {p} isn't tagged فیلد فرم در صفحهٔ {p} برچسبدار نیست + {} annotation on page {p} isn't tagged یادداشت {} در صفحهٔ {p} برچسبدار نیست + Page {p} doesn't tab in structure order صفحهٔ {p} به ترتیب ساختار پیمایش زبانه نمی‌شود + Font {} on page {p} doesn't map its characters to Unicode قلم {} در صفحهٔ {p} نویسه‌هایش را به Unicode نگاشت نمی‌کند + Font (unnamed) on page {p} doesn't map its characters to Unicode قلم (بینام) در صفحهٔ {p} نویسه‌هایش را به Unicode نگاشت نمی‌کند + Field "{}" on page {p} has no description (tooltip) فیلد «{}» در صفحهٔ {p} توضیحی ندارد (راهنمای ابزار) + {ty} element: {what} عنصر {ty}: {what} + {ty} element on page {p}: {what} عنصر {ty} در صفحهٔ {p}: {what} + no alternate text بدون متن جایگزین + its alternate text is inside another element's and will never be read متن جایگزین آن داخل متن عنصری دیگر است و هرگز خوانده نمی‌شود + alternate text on an element with no page content متن جایگزین روی عنصری بدون محتوای صفحه + alternate text hides an annotation inside it متن جایگزین یادداشتی را در داخل خود پنهان می‌کند + not in a Table, THead, TBody or TFoot داخل Table، THead، TBody یا TFoot نیست + not in a TR داخل TR نیست + no header cells (TH) بدون سلول‌های سرصفحه (TH) + rows have between {min} and {max} columns سطر‌ها بین {min} و {max} ستون دارند + no summary بدون خلاصه + not in an L (list) داخل L (فهرست) نیست + not in an LI (list item) داخل LI (مورد فهرست) نیست + the first heading is an H{n}; headings should not skip levels نخستین سرفصل یک H{n} است؛ سرفصل‌ها نباید سطوح را جا بی‌ندازند + H{n} follows an H{d}; headings should not skip levels H{n} بعد از H{d} می‌آید؛ سرفصل‌ها نباید سطوح را جا بی‌ندازند + … and {n} more … و {n} مورد دیگر + Accessibility Report گزارش دسترس‌پذیری + Filename نام فایل + Report created گزارش ایجاد شد + The checker found problems which may prevent the document from being fully accessible. بررسی‌کننده مشکلاتی یافت که ممکن است مانع دسترس‌پذیری کامل سند شوند. + Detailed Report گزارش تفصیلی + Rule Name نام قاعده + The metadata doesn't identify the file as {} فراداده فایل را به‌عنوان {} شناسایی نمی‌کند + Page {} has additional actions صفحهٔ {} اقدام‌های اضافی دارد + A {} action isn't allowed اقدام {} مجاز نیست + {} annotations aren't allowed یادداشت‌های {} مجاز نیستند + A {} annotation isn't set to print, or is hidden یادداشت {} برای چاپ تنظیم نشده، یا پنهان است + A {} annotation has no appearance stream یادداشت {} جریان ظاهری ندارد + A {} annotation has additional actions یادداشت {} اقدام‌های اضافی دارد + The font {} isn't embedded قلم {} جا‌سازی نشده است + A font isn't embedded قلمی جا‌سازی نشده است + The signature has no valid byte range. امضا باز‌های از بایت‌های معتبر ندارد. + The signature's byte range does not match the file. بازهٔ بایت‌های امضا با فایل مطابقت ندارد. + The signature's byte range does not exclude exactly its contents. بازهٔ بایت‌های امضا دقیقاً محتوای آن را مستثنا نمی‌کند. + The signature contents are not hexadecimal. محتوای امضا هگزادسیمال نیست. + The signature could not be read ({}). امضا خوانده نشد ({}). + PdfCraft can't check this signature yet: {}. PdfCraft هنوز نمی‌تواند این امضا را بررسی کند: {}. + The signature uses the legacy adbe.x509.rsa_sha1 format. امضا از قالب قدیمی adbe.x509.rsa_sha1 استفاده می‌کند. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. امضا به‌صورت BER رمز‌گذاری شده است (طول‌های نامعین) نه DER؛ با اغماض خوانده شد. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. محتوای امضا‌شده یک OCTET STRING ساخته‌شده (بخشبخش) است، همان‌طور که BER اجازه می‌دهد؛ به هرحال خوانده شد. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. مقدار امضای RSA طول پیمان‌های کلید را ندارد (بایت‌های صفر ابتدایی حذف یا افزوده شدهاند)؛ به هرحال خوانده شد. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo امضای RSA پارامتر NULL ندارد (امضاکنندگان قدیمی‌تر آن را چنین می‌نویسند)؛ به هرحال خوانده شد. + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. امضای ECDSA در DER متعارف نیست (بایت‌های انت‌هایی، پرشدگی یا نبود بایت علامت)؛ بههرحال خوانده شد. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. امضای ECDSA مقادیر خام r و s است نه یک دنبالهٔ DER؛ بههرحال خوانده شد. + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. الگوریتم امضا و الگوریتم چکیدهٔ SignerInfo هش‌های متفاوتی را نام می‌برند؛ امضا با هش SignerInfo ساخته شده است. + The signature's signed attributes have no content type, which CMS requires; it was read anyway. ویژگی‌های امضا‌شدهٔ امضا نوع محتوا ندارند، که CMS آن را الزامی می‌داند؛ بههرحال خوانده شد. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. این امضا از SHA-1 استفاده می‌کند، یک الگوریتم هش ضعیف: برخورد‌ها در آن به اثبات رسیده است. همچنان معتبر است چون بسیاری از اسناد امضا‌شده سال‌ها پیش از آن استفاده می‌کنند، اما نباید به آن اعتماد کرد. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. این امضا از RIPEMD-160 استفاده می‌کند، یک الگوریتم هش قدیمی ۱۶۰ بیتی که دیگر برای امضا توصیه نمی‌شود. همچنان معتبر است، اما نباید به آن اعتماد کرد. + This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. این امضا از یک کلید RSA کوتاه‌تر از ۲۰۴۸ بیت استفاده می‌کند که دیگر برای امضا‌ها بهاندازهٔ کافی قوی در نظر گرفته نمی‌شود. همچنان معتبر است، اما نباید به آن اعتماد کرد. + The signature's signed attributes have no message digest, so the signature does not cover the document. ویژگی‌های امضا‌شدهٔ امضا چکیدهٔ پیام ندارند، بنابراین امضا سند را پوشش نمی‌دهد. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. ویژگی‌های امضا‌شدهٔ امضا نوع محتوایی غیر از داده را نام می‌برند، بنابراین امضای سند نیست. + The document has been altered or corrupted since the signature was applied. سند از زمان اعمال امضا تغییر کرده یا خراب شده است. + The signer's certificate is not in the signature. گواهی امضا‌کننده در امضا نیست. + The signature value does not match the signer's certificate: the signature is corrupt. مقدار امضا با گواهی امضا‌کننده مطابقت ندارد: امضا خراب است. + The signature could not be checked ({}). امضا بررسی نشد ({}). + The signature could not be verified ({}). امضا تأیید نشد ({}). + This document has not been modified since this signature was applied. این سند از زمان اعمال این امضا تغییر نکرده است. + The document has been modified since this signature was applied, but the changes are permitted ({}). سند از زمان اعمال این امضا تغییر کرده است، اما تغییرات مجاز هستند ({}). + The document has been altered since this signature was applied in ways it does not permit ({}). سند از زمان اعمال این امضا به روش‌هایی تغییر کرده که مجاز نمی‌داند ({}). + The signer's certificate was not valid at the time of signing. گواهی امضا‌کننده در زمان امضا معتبر نبود. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). گواهی امضا‌کننده امضا‌های دیجیتال را مجاز نمی‌داند (کاربرد کلید آن نه امضای دیجیتال دارد و نه انکارنا‌پذیری). + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). گواهی امضا‌کننده برای امضای اسناد صادر نشده است (کاربرد کلید گستردهٔ آن هیچ هدف امضای سند ندارد). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). گواهی امضا‌کننده افزونه‌های حیاتی دارد که PdfCraft آن‌ها را نمی‌شناسد ({}). + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. گواهی {} یک گواهی CA که بتواند گواهی صادر کند نیست، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. گواهی {} افزونه‌های حیاتی دارد که PdfCraft آن‌ها را نمی‌شناسد، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. گواهی {} این تعداد گواهی CA در زیر خود را مجاز نمی‌داند (طول مسیر)، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. گواهی {} در زمان امضا معتبر نبود، بنابراین برای تأیید {} استفاده نمی‌شود. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. هویت امضا‌کننده ناشناخته است زیرا در فهرست گواهی‌های مورد اعتماد شما گنجانده نشده و هیچیک از گواهی‌های والد آن گواهی مورد اعتماد نیستند. + The signer's identity is valid. هویت امضا‌کننده معتبر است. + The signature includes an embedded timestamp. امضا شامل یک مهر زمانی جا‌سازی‌شده است. + Signing time is from the clock on the signer's computer. زمان امضا از ساعت رایانهٔ امضا‌کننده گرفته شده است. + The signature's timestamp token does not cover the signature value. نشانهٔ مهر زمانی امضا مقدار امضا را پوشش نمی‌دهد. + The signature's timestamp token could not be verified ({}). نشانهٔ مهر زمانی امضا تأیید نشد ({}). + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. نشانهٔ مهر زمانی جا‌سازی‌شده معتبر است و مرجع آن مورد اعتماد است؛ زمان مورد اعتماد {} است. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. امضا یک مهر زمانی تأیید‌نشده حمل می‌کند ({}): مرجع مهر زمانی در فهرست گواهی‌های مورد اعتماد شما نیست، بنابراین در عوض از زمان امضایی که امضا‌کننده ادعا می‌کند استفاده می‌شود. + The signer's certificate has been revoked ({}). گواهی امضا‌کننده باطل شده است ({}). + The document's embedded revocation information shows that the signer's certificate has not been revoked. اطلاعات ابطال جا‌سازی‌شده در سند نشان می‌دهد که گواهی امضا‌کننده باطل نشده است. + The timestamp has no valid byte range. مهر زمانی بازهٔ بایت‌های معتبر ندارد. + The timestamp's byte range does not match the file. بازهٔ بایت‌های مهر زمانی با فایل مطابقت ندارد. + The timestamp's byte range does not exclude exactly its contents. بازهٔ بایت‌های مهر زمانی دقیقاً محتوای آن را مستثنا نمی‌کند. + The timestamp contents are not hexadecimal. محتوای مهر زمانی هگزادسیمال نیست. + The timestamp token could not be read ({}). نشانهٔ مهر زمانی خوانده نشد ({}). + The document has been altered or corrupted since the timestamp was applied. سند از زمان اعمال مهر زمانی تغییر کرده یا خراب شده است. + The timestamp authority's certificate was not valid at the time of timestamping. گواهی مرجع مهر زمانی در زمان مهر زدن معتبر نبود. + The timestamp authority's certificate is not issued for time stamping. گواهی مرجع مهر زمانی برای مهر زدن زمان صادر نشده است. + This document has not been modified since this timestamp was applied. این سند از زمان اعمال این مهر زمانی تغییر نکرده است. + The document has been modified since this timestamp was applied, but the changes are permitted ({}). سند از زمان اعمال این مهر زمانی تغییر کرده است، اما تغییرات مجاز هستند ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). سند از زمان اعمال این مهر زمانی به روش‌هایی تغییر کرده که مجاز نمی‌داند ({}). + The timestamp token is valid and its authority is trusted. نشانهٔ مهر زمانی معتبر است و مرجع آن مورد اعتماد است. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. نشانهٔ مهر زمانی معتبر است، اما مرجع مهر زمانی در فهرست گواهی‌های مورد اعتماد شما نیست. + Unsigned signature field فیلد امضای امضا‌نشده + Signature is valid امضا معتبر است + Signature validity is unknown اعتبار امضا ناشناخته است + Signature is invalid امضا نامعتبر است + page content محتوای صفحه + form fill پرشدن فرم + comments یادداشت‌ها + document security store مخزن امنیت سند + metadata فراداده + links پیوند‌ها + document structure ساختار سند + pages added or removed صفحه‌های افزوده یا حذف‌شده + other changes تغییرات دیگر + the signed version could not be read نسخهٔ امضا‌شده خوانده نشد + page {n} is out of range (1–{count}) صفحهٔ {n} خارج از محدوده است (۱–{count}) + {page} is not a page number or label {page} یک شماره یا برچسب صفحه نیست + Change Title تغییر عنوان + Change Author تغییر نویسنده + Change Subject تغییر موضوع + Change Keywords تغییر کلیدواژه‌ها + Save as {level} ذخیره به‌صورت {level} + Remove {standard} identification حذف شناسهٔ {standard} + Create links ایجاد پیوند‌ها +@plural {n} object selected|{n} objects selected {n} شیء انتخاب‌شده|{n} شیء انتخاب‌شده + Drag the selection to move it. Esc clears the selection. برای جابه‌جایی، انتخاب را بکشید. Esc انتخاب را پاک می‌کند. + Shift-click to select several objects. Drag empty space to select an area. برای انتخاب چند شیء، با Shift کلیک کنید. برای انتخاب یک ناحیه، فضای خالی را بکشید. + Select at most 1000 objects at a time. حداکثر ۱۰۰۰ شیء را در یک زمان انتخاب کنید. + Finish or discard the text being edited before selecting several objects. پیش از انتخاب چند شیء، ویرایش متن را تمام کنید یا رهایش کنید. + Move object جابه‌جایی شیء + Move objects جابه‌جایی اشیا + Vertical text cannot be moved with this selection. متن عمودی را نمی‌توان با این انتخاب جابهجا کرد. + Text used as a clipping path cannot be moved with this selection. متنی که به‌عنوان مسیر برش استفاده شده را نمی‌توان با این انتخاب جابهجا کرد. + These objects could not be moved. Select them again and try a smaller move. این اشیا جابه جا نشدند. دوباره آن‌ها را انتخاب کنید و جابه‌جایی کوچکتری را امتحان کنید. + This page contains objects that cannot be selected together. این صفحه شامل اشیایی است که نمی‌توان آن‌ها را با هم انتخاب کرد. + Fill colour رنگ پر‌کردن + No fill بدون پرشدگی + Fill colour: none رنگ پر‌کردن: هیچ +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space فاصله + Grid view نمای شبکه ای + List view نمای فهرستی + Files are combined in the order shown. Drag them to change it. فایل‌ها به ترتیب نمایش‌داده‌شده ترکیب می‌شوند. برای تغییر آن، آن‌ها را بکشید. + Preview unavailable: {e} پیش‌نمایش در دسترس نیست: {e} + Pages: {k} of {n} صفحه‌ها: {k} از {n} + Check the pages صفحه‌ها را بررسی کنید + Switch to List view to change the pages it takes. برای تغییر صفحه‌هایی که برمی‌دارد، به نمای فهرستی بروید. + Expand all گسترش همه + Collapse all جمع کردن همه + Collapse all pages جمع کردن همهٔ صفحه‌ها + Expand all pages گسترش همهٔ صفحه‌ها + Remove page حذف صفحه + Remove {n} pages حذف {n} صفحه + Reset panel layout باز‌نشانی چیدمان پنل‌ها + Float panel شناور کردن پنل + Move to group انتقال به گروه + Panels پنل‌ها + Resize panels تغییر اندازهٔ پنل‌ها + Resize panel window تغییر اندازهٔ پنجرهٔ پنل + Tools ابزار‌ها + Inspector بازرس + PDF page: {size} صفحهٔ PDF: {size} + Match page at 100% تناسب کاغذ با صفحه در ۱۰۰٪ + PDF scale: {scale}% مقیاس PDF: {scale}% + Printer paper and scaling may differ. Check driver settings. کاغذ و مقیاس چاپگر ممکن است متفاوت باشد. تنظیمات درایور را بررسی کنید. + Drawing scale changes. Use Actual size for full-scale plans. مقیاس نقشه تغییر می‌کند. برای نقشه‌های با اندازه کامل از «اندازه واقعی» استفاده کنید. + Select page {label} انتخاب صفحه {label} + Rotate page {label} counterclockwise چرخش صفحه {label} در خلاف جهت عقربه‌های ساعت + Rotate page {label} clockwise چرخش صفحه {label} در جهت عقربه‌های ساعت + Delete page {label} حذف صفحه {label} + Scroll speed (mouse wheel and arrow keys) سرعت پیمایش (چرخ ماوس و کلید‌های جهت) +scroll speed Slow کند +scroll speed Normal عادی +scroll speed Fast تند + Animation effects جلوه‌های پویانمایی +animation effects On روشن +animation effects Off خاموش + Enhance camera image بهبود تصویر دوربین + Drag the corners onto the document's edges, then choose Enhance page. گوشه‌ها را روی لبه‌های سند بکشید، سپس «بهبود صفحه» را انتخاب کنید. + Enhance page بهبود صفحه + Done adding text پایان افزودن متن + Discard this text (Esc) کنار گذاشتن این متن (Esc) + Certificate authority مرجع گواهی + End entity موجودیت نهایی + Clean-up پاک‌سازی + Original اصلی + Greyscale خاکستری + Black and white سیاه و سفید + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… درایور این چاپگر تنظیمات دوطرفه را از برنامه‌های دیگر نمی‌پذیرد، بنابراین تنظیم خودش اعمال می‌شود. آن را در «ویژگی‌ها…» تغییر دهید… + Browser's print dialog پنجره چاپ مرورگر + Copies, two-sided printing and colour are chosen in the browser's print dialog. تعداد نسخه‌ها، چاپ دوطرفه و رنگ در پنجره چاپ مرورگر انتخاب می‌شوند. + Preparing {n} sheets for printing… آماده‌سازی {n} برگ برای چاپ… + Preparing sheet {done} of {total} for printing… آماده‌سازی برگ {done} از {total} برای چاپ… + Opening the browser's print dialog… باز کردن پنجره چاپ مرورگر… + Couldn't print from the browser ({e}); downloaded {name} to print instead چاپ از مرورگر ممکن نشد ({e})؛ در عوض {name} برای چاپ بار‌گیری شد + Tools panel side سمت پنل ابزار‌ها + Add comment افزودن یادداشت + Change tag properties تغییر ویژگی‌های برچسب + Tags panel پنل برچسب‌ها + Tags برچسب‌ها + Tag type نوع برچسب + Actual text متن واقعی + Find a tag یافتن برچسب + Collapse tag جمع‌کردن برچسب + Expand tag گستردن برچسب + Revert changes باز‌گرداندن تغییرات + Save tag properties ذخیره ویژگی‌های برچسب + This document has no editable structure tags. این سند هیچ برچسب ساختاری قابل ویرایشی ندارد. + {n} tags {n} برچسب + Save or revert changes before selecting another tag. پیش از انتخاب برچسبی دیگر، تغییرات را ذخیره یا باز‌گردانید. + Left out: {names} کنار گذاشته‌شده: {names} + already a PDF از قبل PDF است + no unused output name نام خروجی بدون استفاده موجود نیست + no output folder was chosen هیچ پوشه خروجی انتخاب نشد +@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF بار‌گیری شد|{n} PDF بار‌گیری شد +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} محاسبه اکنون از نام جدید استفاده می‌کند.|{n} محاسبه اکنون از نام جدید استفاده می‌کنند. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. ترتیب محاسبه {n} مورد دارد که چیزی محاسبه نمی‌کند. «اصلاح ترتیب» آن را حذف می‌کند.|ترتیب محاسبه {n} مورد دارد که چیزی محاسبه نمی‌کنند. «اصلاح ترتیب» آن‌ها را حذف می‌کند. +@plural {fields} reads it|{fields} read it {fields} آن را می‌خواند|{fields} آن را می‌خوانند +@plural {n} field shows an old value|{n} fields show old values {n} فیلد مقدار قدیمی نشان می‌دهد|{n} فیلد مقدار‌های قدیمی نشان می‌دهند +@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} پیش از {fields} محاسبه می‌شود، در حالی که آن را می‌خواند.|{field} پیش از {fields} محاسبه می‌شود، در حالی که آن‌ها را می‌خواند. +@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} فیلد دیگر زود‌تر از موعد محاسبه می‌شود.|{n} فیلد دیگر زود‌تر از موعد محاسبه می‌شوند. +@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} در ترتیب ذخیره‌شده در PDF وجود ندارد، بنابراین سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.|{fields} در ترتیب ذخیره‌شده در PDF وجود ندارند، بنابراین سایر نمایشگر‌های PDF ممکن است آن‌ها را محاسبه نکنند. + Scan اسکن + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. روی یک برگ کاغذ سفید امضا کنید و از آن در نور روشن و یکنواخت عکس بگیرید (از فلاش استفاده کنید). عکس را انتخاب کنید یا آن را اینجا رها کنید: PdfCraft امضا را پیدا می‌کند و کاغذ را حذف می‌کند. + Choose photo… انتخاب عکس… + No photo chosen هیچ عکسی انتخاب نشده + Photo عکس + Ink sensitivity حساسیت جوهر + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. هیچ امضایی در کادر یافت نشد. کادر را روی امضا ببرید یا حساسیت جوهر را بالا ببرید. + Couldn't use the photo: {e} استفاده از عکس ممکن نشد: {e} + Close Tab بستن زبانه + Reopen Closed Tab باز‌گشایی زبانه بسته‌شده + Reveal in Finder نمایش در Finder + Reveal in File Explorer نمایش در File Explorer + Other colour… رنگ دیگر… + Recent colours رنگ‌های اخیر + Reading colours رنگ‌های خواندن + Replace document colours جای‌گزینی رنگ‌های سند + Page background پس‌زمینه صفحه + Only change the colour of black text or line art فقط رنگ متن سیاه یا طرح‌های خطی را تغییر دهید + Change the colour of line art as well as text رنگ طرح‌های خطی و همچنین متن را تغییر دهید + Colours are remapped while rendering; the document itself never changes. رنگ‌ها هنگام نمایش باز‌تصویر می‌شوند؛ خود سند هرگز تغییر نمی‌کند. + Black on white سیاه روی سفید + White on black سفید روی سیاه + Yellow on black زرد روی سیاه + Green on black سبز روی سیاه + Couldn't follow linked PDF: {reason} پی‌گیری PDF پیوند‌شده ممکن نشد: {reason} + The linked PDF opened, but its named destination was not applied. PDF پیوند‌شده باز شد، اما مقصد نامدار آن اعمال نشد. + The linked PDF opened, but its page destination was out of range. PDF پیوند‌شده باز شد، اما مقصد صفحه آن خارج از محدوده بود. + Local cross-PDF links require the desktop app پیوند‌های محلی میان PDF به کاربردسطح رومیزی نیاز دارند + The existing action will be preserved. کنش موجود حفظ خواهد شد. + Cut Image برش تصویر + Redact Image سیاه‌کردن تصویر + Create Link… ایجاد پیوند… + Recognize Text… تشخیص متن… + Minimize کوچک‌کردن + Maximize بزرگ‌کردن + Restore باز‌گرداندن + Use system title bar استفاده از نوار عنوان سیستم + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. به‌جای نوار زبانه‌ها، یک نوار عنوان ساده برای دکمه‌های پنجره نمایش دهید. GNOME برای برنامه‌ها نوار عنوان نمی‌کشد، بنابراین این یکی از پوسته آن پیروی نمی‌کند. + Allow اجازه دادن + Only allow this if you trust the source document. فقط اگر به سند منبع اعتماد دارید این را اجازه دهید. + Open in Full Screen mode? در حالت تمامصفحه باز شود؟ + This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. این سند درخواست باز شدن در حالت تمام صفحه را دارد: صفحه تمام نمایشگر را پر می‌کند و چیز دی‌گری نمایش داده نمی‌شود. هر زمان برای خروج Esc را فشار دهید. + Full Screen تمامصفحه + Not now اکنون نه + Open this local PDF? این PDF محلی باز شود؟ + This document is trying to open a local file: این سند تلاش می‌کند یک فایل محلی را باز کند: + Move tag earlier انتقال برچسب به قبل + Move tag later انتقال برچسب به بعد + Turn upside down (180°) چرخش وارونه (۱۸۰°) + 4 x 6 in label (101.6 x 152.4 mm) برچسب 4 x 6 in (101.6 x 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. هر صفحه را به ناحیه چاپ شد‌ه اش می‌برد، آن را مستقیم می‌کند و بدون کشیدگی روی برگ جا می‌دهد. یک برچسب در هر برگ. + Find untagged content یافتن محتوای بدون برچسب + No untagged content was found. هیچ محتوای بدون برچسبی یافت نشد. + Found {n} untagged content problems. {n} مشکل محتوای بدون برچسب یافت شد. + Tag form fields برچسب‌گذاری فیلد‌های فرم + Mark as artifact علامت‌گذاری به‌عنوان قطعه + Remove this leaf tag from reading order and mark its direct content as an artifact این برچسب برگ را از ترتیب خواندن حذف کنید و محتوای مستقیم آن را به‌عنوان قطعه علامت‌گذاری کنید + Mark content as artifact علامت‌گذاری محتوا به‌عنوان قطعه + Word count شمارش واژه‌ها + Current page only فقط صفحه کنونی + Exclude tagged tables کنار گذاشتن جدول‌های برچسبدار + Exclude tagged code blocks کنار گذاشتن بلوک‌های کد برچسبدار + Exclude image/OCR text کنار گذاشتن متن تصویر/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. کنارگذاشتن‌ها از برچسب‌های PDF استفاده می‌کنند. جدول‌ها و کد‌های بدون برچسب همچنان شامل می‌شوند. کنار گذاشتن تصویر متن نامرئی را نیز حذف می‌کند. + Words واژه‌ها + Characters نویسه‌ها + Characters without spaces نویسه‌ها بدون فاصله + Pages counted صفحه‌های شمرده‌شده + Some pages contain no countable text. Scanned images need OCR first. برخی صفحه‌ها هیچ متن قابل شمارشی ندارند. تصویر‌های اسک‌ن شده نخست به OCR نیاز دارند. + Some content could not be classified safely and was kept in the count. برخی محتوا به‌طور ایمن طبقه‌بندی نشد و در شمارش نگه داشته شد. + Counting words… {n} pages read شمارش واژه‌ها… {n} صفحه خوانده شد + Word counting stopped unexpectedly. شمارش واژه‌ها به‌طور غیر‌منتظره متوقف شد. + Word count… شمارش واژه‌ها… + Collapse all tools جمع‌کردن همه ابزارها + Expand all tools گسترش همه ابزارها + Create & Edit ایجاد و ویرایش + Customize & Automate شخصی‌سازی و خودکارسازی + Forms & Signatures فرم‌ها و امضاها + Protect & Standardize محافظت و استانداردسازی + Review & Compare بازبینی و مقایسه + No tools match your search. هیچ ابزاری با جست‌وجوی شما مطابقت ندارد. +@plural {n} reply|{n} replies {n} پاسخ|{n} پاسخ +@id file.word_count شمارش واژه‌ها… + Standard استاندارد + Mobile موبایل + Merge image fragments ادغام قطعه‌های تصویر + Optimizing… merging image fragments بهینه‌سازی… ادغام قطعه‌های تصویر + Side by side کنار هم + New file only فقط فایل جدید + Old file: {name} فایل قدیمی: {name} + New file: {name} فایل جدید: {name} + Print as image چاپ به‌صورت تصویر + Resolution: تفکیک‌پذیری: + Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. زمانی از آن استفاده کنید که چاپگر متن، سایه یا شفافیت را نادیده می‌گیرد. چاپ دیگر متن قابل جستوجو نیست؛ با «چاپ در مقیاس خاکستری»، تصاویر خاکستری می‌شوند. + Crop Image برش تصویر + Remove Crop حذف برش + Trim the picture with its handles; Enter or Done applies تصویر را با دستگیره‌هایش برش دهید؛ Enter یا «انجام شد» آن را اعمال می‌کند + Redaction mark appearance ظاهر نشانه سیاه‌کردن + Outline colour: رنگ خط دور: + Fill colour: رنگ پر‌کردن: + Shown while the pointer is over a mark. زمانی که اشاره گر روی یک نشانه است نمایش داده می‌شود. + Opacity: کدری: + Make properties default ویژگی‌ها را پیش‌فرض کن + Pages selected صفحه‌های انتخاب‌شده + Page properties ویژگی‌های صفحه + Font قلم + Bulleted list فهرست نشانهدار + Numbered list فهرست شمارهدار + Alignment هم‌ترازی + Superscript بالانویس + Subscript زیرنویس + Paragraph spacing فاصله بند‌ها + Less کمتر + Other options گزینه‌های دیگر + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. شما در حال مشاهده نسخه امضا‌شده «{from}» (باز‌نگری {rev}) هستید. این یک تصویر لحظ‌های است: تغییرات اینجا بر نسخه اصلی اثر نمی‌گذارد. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. شما در حال مشاهده باز‌نگری {rev} از «{from}» هستید. این یک تصویر لحظ‌های است: تغییرات اینجا بر نسخه اصلی اثر نمی‌گذارد. + Attach a file… پیوست یک فایل… + Delete attachment حذف پیوست + Attaching files is not available on the web yet پیوست‌کردن فایل‌ها هنوز در وب در دسترس نیست + Attach {name} پیوست {name} + Delete attachment {key} حذف پیوست {key} + Thumbnail size اندازه بندانگشتی‌ها + Smaller thumbnails بندانگشتی‌های کوچک‌تر + Larger thumbnails بندانگشتی‌های بزرگ‌تر + Reshape comment تغییر شکل یادداشت +calendar date January ژانویه +calendar date February فوریه +calendar date March مارس +calendar date April آوریل +calendar date May مه +calendar date June ژوئن +calendar date July ژوئیه +calendar date August اوت +calendar date September سپتامبر +calendar date October اکتبر +calendar date November نوامبر +calendar date December دسامبر +calendar weekday Mo دو +calendar weekday Tu سه +calendar weekday We چه +calendar weekday Th پن +calendar weekday Fr جم +calendar weekday Sa شن +calendar weekday Su یک +certificate Issued to صادر‌شده برای +certificate Issued by صادر‌شده توسط +certificate Issuer صادر‌کننده +certificate Serial number شماره سریال +certificate Version نگارش +certificate Public key کلید عمومی +certificate Key usage کاربرد کلید +certificate Intended usage کاربرد موردنظر +certificate Basic constraints محدودیت‌های پایه +certificate Self-signed خودامضا +certificate Valid from معتبر از +certificate Valid to معتبر تا +certificate Validity starts آغاز اعتبار +certificate Validity ends پایان اعتبار +certificate SHA-1 digest چکیده SHA-1 +certificate SHA-256 digest چکیده SHA-256 + Digital Signature امضای دیجیتال + Non-Repudiation انکارنا‌پذیری + Key Encipherment رمز‌گذاری کلید + Data Encipherment رمز‌گذاری داده + Key Agreement توافق کلید + Certificate Signing امضای گواهی + CRL Signing امضای CRL + Encipher Only فقط رمز‌گذاری + Decipher Only فقط رمزگشایی + {who}, certificate issued by {issuer}. {who}، گواهی صادر‌شده توسط {issuer}. + {who}, certificate issued by an unknown issuer. {who}، گواهی صادر‌شده توسط صادرکنند‌های ناشناخته. + Rev. {rev}: Signed by {name} باز‌نگری {rev}: امضا‌شده توسط {name} + Rev. {rev}: Signed by an unknown signer باز‌نگری {rev}: امضا‌شده توسط امضاکنند‌های ناشناخته + Rev. {rev}: Certified by {name} باز‌نگری {rev}: گواهی‌شده توسط {name} + Rev. {rev}: Certified by an unknown signer باز‌نگری {rev}: گواهی‌شده توسط امضاکنند‌های ناشناخته + Unsigned signature field: {name} فیلد امضای بدون امضا: {name} + Choose a digital ID. یک شناسه دیجیتال انتخاب کنید. + There is no folder to save the digital ID in. هیچ پوش‌های برای ذخیره شناسه دیجیتال وجود ندارد. + The password is incorrect. گذرواژه نادرست است. + The password must have at least 6 characters. گذرواژه باید دستکم ۶ نویسه داشته باشد. + The password must have at least 8 characters. گذرواژه باید دستکم ۸ نویسه داشته باشد. + The passwords do not match. گذرواژه‌ها یکسان نیستند. + Only the document's owner can change its security (open it with the permissions password) فقط مالک سند می‌تواند امنیت آن را تغییر دهد (آن را با گذرواژه مجوز‌ها باز کنید) +@plural {n} byte|{n} bytes {n} بایت|{n} بایت +@plural {n} pixel|{n} pixels {n} پیکسل|{n} پیکسل +@plural {n} file|{n} files {n} فایل|{n} فایل +@plural {n} comment|{n} comments {n} یادداشت|{n} یادداشت +@plural {n} result|{n} results {n} نتیجه|{n} نتیجه +@plural {n} image optimized|{n} images optimized {n} تصویر بهینه شد|{n} تصویر بهینه شد +@plural {n} item discarded|{n} items discarded {n} مورد کنار گذاشته شد|{n} مورد کنار گذاشته شد +@plural Delete {n} field|Delete {n} fields حذف {n} فیلد|حذف {n} فیلد + Add the PDFs to combine PDFهایی را که می‌خواهید ادغام کنید اضافه کنید + Drop PDFs or folders here, or choose them. PDFها یا پوشه‌ها را اینجا رها کنید، یا آن‌ها را انتخاب کنید. + Every PDF in the folder هر PDF درون پوشه + Every PDF in the folder and the folders inside it هر PDF درون پوشه و پوشه‌های داخل آن + Added the first {n} PDFs in {folder} نخستین {n} PDF در {folder} افزوده شد + No PDFs found in {folder} هیچ PDFی در {folder} یافت نشد + Panel layout could not be restored: {error} چیدمان پنل باز‌گردانده نشد: {error} + Panel move was not applied: {error} جابه‌جایی پنل اعمال نشد: {error} + Rendering… در حال نمایش… + Still rendering — this page is unusually complex… هنوز در حال نمایش — این صفحه به‌طور غیر‌معمول پیچیده است… + Loading… در حال بار‌گذاری… + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. این فایل روی دیسک تغییر کرده است. برای دیدن نسخه جدید آن را دوباره بار‌گذاری کنید؛ تغییرات ذخیره‌نشده شما از دست خواهد رفت. + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). تشخیص متن نصب نشده است: مدل‌های آن وجود ندارند (`cargo xtask models` را اجرا کنید یا PDFCRAFT_MODELS را تنظیم کنید). + Cut and stack needs Two-sided: Off. Print single-sided sheets. برش و چیدن به «دوطرفه: خاموش» نیاز دارد. برگ‌ها را یکرو چاپ کنید. + Error خطا + Warning هشدار + Information اطلاعات + Success موفق + Enter a name. نامی وارد کنید. + Enter a page number شماره صفحه وارد کنید + Enter a URL نشانی URL وارد کنید + Enter the field names نام فیلد‌ها را وارد کنید + Choose… انتخاب… + Choose a file… انتخاب یک فایل… + Choose a folder… انتخاب یک پوشه… + Create folder ایجاد پوشه + Open folder باز کردن پوشه + Save file ذخیره فایل + Save changes? تغییرات ذخیره شود؟ + Theme پوسته +theme Appearance ظاهر + System سیستم + English انگلیسی + Always همیشه + Never هرگز + Automatically به‌طور خودکار + On روشن + Enabled فعال + Disabled غیر‌فعال + Mouse wheel scroll speed سرعت پیمایش چرخ ماوس + Margins (in) حاشیه‌ها (in) + Color رنگ + Folder پوشه + Comment یادداشت + Comment and review یادداشت و باز‌بینی + Creator سازنده + Producer تولید‌کننده + Organize سازمان‌دهی + Import درون‌ریزی + Move جابه‌جایی + Duplicate تکثیر + Paste جای‌گذاری + Select all انتخاب همه + Refresh تازه‌سازی + Next بعدی + Previous قبلی + of از + No results بدون نتیجه + Search results نتایج جستوجو + Copy to clipboard رونوشت در بریدهدان + Copied to clipboard در بریده دان رونوشت شد + Help… راهنما… + on page {label} در صفحه {label} + Page {n} صفحه {n} + Page {n} of {total} صفحه {n} از {total} + Open a PDF (⌘O) باز کردن یک PDF (⌘O) + Print (⌘P) چاپ (⌘P) + Document properties (⌘D) ویژگی‌های سند (⌘D) + New bookmark (⌘B) نشانک جدید (⌘B) + Next (⌘G) بعدی (⌘G) + Previous (⇧⌘G) قبلی (⇧⌘G) + Zoom in (⌘+) بزرگنمایی (⌘+) + Zoom out (⌘−) کوچکنمایی (⌘−) + Zoom in / out (also pinch or ⌘-scroll) بزرگنمایی / کوچکنمایی (همچنین با دو انگشت یا ⌘ + پیمایش) + Rotate view clockwise (⇧⌘+) چرخش نما در جهت عقربه‌های ساعت (⇧⌘+) + Save (M4) ذخیره (M4) + Add text field table افزودن جدول فیلد‌های متنی + Text field table جدول فیلد‌های متنی + Text Field Table جدول فیلد‌های متنی + Columns ستون‌ها + Row spacing فاصله سطر‌ها + Column spacing فاصله ستون‌ها + First row number شماره نخستین سطر + Column widths پهنای ستون‌ها + Distribute evenly تقسیم یکنواخت + Field names نام فیلد‌ها + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N یا {row} شماره سطر است، {col} شماره ستون و {seq} شماره خانه. اگر هیچکدام نباشد، شماره سطر در آخر می‌آید. + Column {n} ستون {n} + Add fields افزودن فیلد‌ها + a table has 1 to {max} rows یک جدول از ۱ تا {max} سطر دارد + a table has 1 to {max} columns یک جدول از ۱ تا {max} ستون دارد + the first row number can be at most {max} شماره نخستین سطر حداکثر می‌تواند {max} باشد + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 ناحیه، فاصله و پهنای ستون‌ها باید عدد باشند، فاصله ۰ یا بیشتر و هر پهنا بیشتر از ۰ + the rows are too short: make the area taller, or use fewer rows or less row spacing سطر‌ها بیش از حد کوتاه اند: ناحیه را بلند‌تر کنید، یا سطر‌های کمتر یا فاصله سطر کمتری به کار ببرید + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing یک ستون بیش از حد باریک است: ناحیه را پهن‌تر کنید، سهم بیشتری به ستون بدهید، یا ستون‌های کمتر یا فاصله ستون کمتری به کار ببرید + field names can't contain a period: {name} نام فیلد‌ها نمی‌تواند شامل نقطه باشد: {name} + two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند + a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد + Today امروز diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cd5a8040de..a876b8aabc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2810,3 +2810,11 @@ theme Appearance Teema Delete attachment Poista liite Delete attachment {key} Poista liite {key} @plural {n} reply|{n} replies {n} vastaus|{n} vastausta + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sivutyökalut + Insert pages from a file after the selection… Lisää sivuja tiedostosta valinnan jälkeen… + Rotate the selected pages counterclockwise Kierrä valittuja sivuja vastapäivään + Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään + Extract the selected pages… Pura valitut sivut… + Delete the selected pages Poista valitut sivut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ea6008039f..bb935754b3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2806,3 +2806,11 @@ certificate Version Version # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Aujourd'hui @plural {n} reply|{n} replies {n} réponse|{n} réponses + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Outils de page + Insert pages from a file after the selection… Insérer des pages d'un fichier après la sélection… + Rotate the selected pages counterclockwise Faire pivoter les pages sélectionnées dans le sens antihoraire + Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire + Extract the selected pages… Extraire les pages sélectionnées… + Delete the selected pages Supprimer les pages sélectionnées diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7f403caba1..8b1de38dc3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2777,3 +2777,11 @@ theme Appearance स्वरूप Delete attachment अनुलग्नक हटाएँ Delete attachment {key} अनुलग्नक {key} हटाएँ @plural {n} reply|{n} replies {n} जवाब + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools पृष्ठ उपकरण + Insert pages from a file after the selection… चयन के बाद किसी फ़ाइल से पृष्ठ डालें… + Rotate the selected pages counterclockwise चयनित पृष्ठों को वामावर्त घुमाएँ + Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ + Extract the selected pages… चयनित पृष्ठ निकालें… + Delete the selected pages चयनित पृष्ठ हटाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33ced9925d..71bf7d36c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,3 +2539,11 @@ animation effects Off Ki # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Ma @plural {n} reply|{n} replies {n} válasz|{n} válasz + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Oldaleszközök + Insert pages from a file after the selection… Oldalak beszúrása fájlból a kijelölés után… + Rotate the selected pages counterclockwise A kijelölt oldalak forgatása az óramutató járásával ellentétesen + Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően + Extract the selected pages… A kijelölt oldalak kibontása… + Delete the selected pages A kijelölt oldalak törlése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 422fac2793..409a04cf46 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2778,3 +2778,11 @@ theme Appearance Tampilan Delete attachment Hapus lampiran Delete attachment {key} Hapus lampiran {key} @plural {n} reply|{n} replies {n} balasan + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Alat halaman + Insert pages from a file after the selection… Sisipkan halaman dari file setelah pilihan… + Rotate the selected pages counterclockwise Putar halaman yang dipilih berlawanan arah jarum jam + Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam + Extract the selected pages… Ekstrak halaman yang dipilih… + Delete the selected pages Hapus halaman yang dipilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 60ecf2de3c..11a9d4869b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2772,3 +2772,11 @@ certificate SHA-256 digest Impronta SHA-256 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Oggi @plural {n} reply|{n} replies {n} risposta|{n} risposte + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Strumenti pagina + Insert pages from a file after the selection… Inserisci pagine da un file dopo la selezione… + Rotate the selected pages counterclockwise Ruota le pagine selezionate in senso antiorario + Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario + Extract the selected pages… Estrai le pagine selezionate… + Delete the selected pages Elimina le pagine selezionate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index db338ebb11..1a8c287bcf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2540,3 +2540,11 @@ animation effects Off オフ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今日 @plural {n} reply|{n} replies {n} 件の返信 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ページツール + Insert pages from a file after the selection… 選択範囲の後にファイルからページを挿入… + Rotate the selected pages counterclockwise 選択したページを反時計回りに回転 + Rotate the selected pages clockwise 選択したページを時計回りに回転 + Extract the selected pages… 選択したページを抽出… + Delete the selected pages 選択したページを削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index ea0d0d0fb4..a9eada9743 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2538,3 +2538,11 @@ animation effects Off Өшірулі # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Бүгін @plural {n} reply|{n} replies {n} жауап|{n} жауап + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Бет құралдары + Insert pages from a file after the selection… Таңдаудан кейін файлдан беттер кірістіру… + Rotate the selected pages counterclockwise Таңдалған беттерді сағат тіліне қарсы бұру + Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру + Extract the selected pages… Таңдалған беттерді шығарып алу… + Delete the selected pages Таңдалған беттерді жою diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a9ee5b4878..98b68e85c7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2777,3 +2777,11 @@ theme Appearance 모양 Delete attachment 첨부 파일 삭제 Delete attachment {key} 첨부 파일 {key} 삭제 @plural {n} reply|{n} replies 답글 {n}개 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 페이지 도구 + Insert pages from a file after the selection… 선택 영역 뒤에 파일에서 페이지 삽입… + Rotate the selected pages counterclockwise 선택한 페이지를 시계 반대 방향으로 회전 + Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 + Extract the selected pages… 선택한 페이지 추출… + Delete the selected pages 선택한 페이지 삭제 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 3b702756bc..331b24501b 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2448,3 +2448,11 @@ animation effects Off ປິດ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ @plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ເຄື່ອງມືໜ້າ + Insert pages from a file after the selection… ແຊກໜ້າຈາກໄຟລ໌ຫຼັງການເລືອກ… + Rotate the selected pages counterclockwise ໝູນໜ້າທີ່ເລືອກທວນເຂັມໂມງ + Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ + Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… + Delete the selected pages ລຶບໜ້າທີ່ເລືອກ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 058bbe9052..2c151b2554 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2801,3 +2801,11 @@ theme Appearance Išvaizda # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Šiandien @plural {n} reply|{n} replies {n} atsakymas|{n} atsakymai|{n} atsakymų + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Puslapių įrankiai + Insert pages from a file after the selection… Įterpti puslapius iš failo po pasirinkimo… + Rotate the selected pages counterclockwise Pasukti pasirinktus puslapius prieš laikrodžio rodyklę + Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę + Extract the selected pages… Išskleisti pasirinktus puslapius… + Delete the selected pages Ištrinti pasirinktus puslapius diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ab5e450428..15a27becbc 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -159,7 +159,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 34] = [ +pub static LANGUAGES: [LangInfo; 35] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -215,6 +215,10 @@ pub static LANGUAGES: [LangInfo; 34] = [ LangInfo { code: "ckb", name: "کوردیی ناوەندی", source: include_str!("ckb.tsv"), plural: plural_one_other, rtl: true, catalog: OnceLock::new() }, + // Persian (Farsi); every `fa-*` locale (`fa-IR`, `fa_IR.UTF-8`, `fa-AF` ...) resolves here. Right + // to left like Arabic: see `LangInfo::rtl`. CLDR `fa` counts have two forms (one: i = 0 or n = 1, + // other), which for whole counts is the French rule, so `@plural` entries list two forms. + LangInfo { code: "fa", name: "فارسی", source: include_str!("fa.tsv"), plural: plural_fr, rtl: true, catalog: OnceLock::new() }, // Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here. LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() }, // Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other). @@ -1672,7 +1676,7 @@ mod tests { assert_eq!(ar.name(), "العربية"); assert!(ar.rtl()); let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect(); - assert_eq!(rtl, ["ar", "ckb"], "Arabic and Kurdish are the right-to-left languages"); + assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages"); assert_eq!(normalize_pref("AR"), Some("ar")); for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] { assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); @@ -1723,6 +1727,39 @@ mod tests { assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); } + fn persian() -> Lang { + Lang::from_code("fa").expect("fa registered") + } + + #[test] + fn persian_is_registered_and_persists() { + let fa = persian(); + assert_eq!(fa.name(), "فارسی"); + assert!(fa.rtl()); + // CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural. + assert_eq!(fa.0.plural_forms(), 2); + assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]); + assert_eq!(normalize_pref("FA"), Some("fa")); + for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] { + assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); + } + assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); + // Lookups return Persian (single words in display order). + assert_eq!(tr(fa, "Save"), "ذخیره"); + assert_eq!(tr(fa, "Cancel"), "لغو"); + assert_eq!(tr(fa, "File"), "فایل"); + // Unknown strings and user text are never reordered by a lookup. + assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "FA").unwrap(); + assert_eq!(app.language, "fa"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "fa"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "fa"); + } + /// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface /// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps /// it; Arabic labels must not need a Japanese face). diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index bc5fc98f58..75c926a0e3 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2716,3 +2716,11 @@ key Space Mellomrom # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Gjenopprett den siste visningen (side, zoom og sidevisning) når et dokument åpnes igjen @plural {n} reply|{n} replies {n} svar|{n} svar + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sideverktøy + Insert pages from a file after the selection… Sett inn sider fra en fil etter utvalget… + Rotate the selected pages counterclockwise Roter de valgte sidene mot klokken + Rotate the selected pages clockwise Roter de valgte sidene med klokken + Extract the selected pages… Trekk ut de valgte sidene… + Delete the selected pages Slett de valgte sidene diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 056ca7d403..7d76739499 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2636,3 +2636,11 @@ animation effects Off Wyłączone # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dzisiaj @plural {n} reply|{n} replies {n} odpowiedź|{n} odpowiedzi|{n} odpowiedzi + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Narzędzia stron + Insert pages from a file after the selection… Wstaw strony z pliku po zaznaczeniu… + Rotate the selected pages counterclockwise Obróć zaznaczone strony w lewo + Rotate the selected pages clockwise Obróć zaznaczone strony w prawo + Extract the selected pages… Wyodrębnij zaznaczone strony… + Delete the selected pages Usuń zaznaczone strony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1434c7c6e2..371c8cc142 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2825,3 +2825,11 @@ theme Appearance Aparência # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoje @plural {n} reply|{n} replies {n} resposta|{n} respostas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Ferramentas de página + Insert pages from a file after the selection… Inserir páginas de um arquivo após a seleção… + Rotate the selected pages counterclockwise Girar as páginas selecionadas no sentido anti-horário + Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário + Extract the selected pages… Extrair as páginas selecionadas… + Delete the selected pages Excluir as páginas selecionadas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 7c739932e2..09b313e734 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2801,3 +2801,11 @@ theme Appearance Aspect Delete attachment Ștergere atașament Delete attachment {key} Ștergere atașament {key} @plural {n} reply|{n} replies {n} răspuns|{n} răspunsuri|{n} de răspunsuri + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Instrumente pentru pagini + Insert pages from a file after the selection… Inserează pagini dintr-un fișier după selecție… + Rotate the selected pages counterclockwise Rotește paginile selectate în sens antiorar + Rotate the selected pages clockwise Rotește paginile selectate în sens orar + Extract the selected pages… Extrage paginile selectate… + Delete the selected pages Șterge paginile selectate diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bea98cd797..0523fe70cc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2453,3 +2453,11 @@ animation effects Off Выкл # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сегодня @plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменты страниц + Insert pages from a file after the selection… Вставить страницы из файла после выделения… + Rotate the selected pages counterclockwise Повернуть выбранные страницы против часовой стрелки + Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке + Extract the selected pages… Извлечь выбранные страницы… + Delete the selected pages Удалить выбранные страницы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index ccbb42d57b..3a7ea48c25 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2545,3 +2545,11 @@ animation effects Off Izklopljeni # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Danes @plural {n} reply|{n} replies {n} odgovor|{n} odgovora|{n} odgovori|{n} odgovorov + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Orodja za strani + Insert pages from a file after the selection… Vstavi strani iz datoteke za izborom… + Rotate the selected pages counterclockwise Zavrti izbrane strani v nasprotni smeri urnega kazalca + Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca + Extract the selected pages… Izvleci izbrane strani… + Delete the selected pages Izbriši izbrane strani diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index a720a5333f..70a6fbad77 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2527,3 +2527,11 @@ animation effects Off Joaktiv Delete attachment Fshi bashkëngjitjen Delete attachment {key} Fshi bashkëngjitjen {key} @plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Mjetet e faqes + Insert pages from a file after the selection… Fut faqe nga një skedar pas përzgjedhjes… + Rotate the selected pages counterclockwise Rrotullo faqet e përzgjedhura në drejtim të kundërt me orën + Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës + Extract the selected pages… Nxirr faqet e përzgjedhura… + Delete the selected pages Fshi faqet e përzgjedhura diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 317d47f9b9..2c8808aa40 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2449,3 +2449,11 @@ animation effects Off ఆఫ్ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు @plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools పేజీ సాధనాలు + Insert pages from a file after the selection… ఎంపిక తర్వాత ఫైల్ నుండి పేజీలను చొప్పించు… + Rotate the selected pages counterclockwise ఎంచుకున్న పేజీలను అపసవ్య దిశలో తిప్పు + Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు + Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… + Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 92bb15f1ad..597a0dbb9e 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2703,3 +2703,11 @@ theme Appearance ลักษณะที่ปรากฏ # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง @plural {n} reply|{n} replies การตอบกลับ {n} รายการ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools เครื่องมือหน้า + Insert pages from a file after the selection… แทรกหน้าจากไฟล์หลังส่วนที่เลือก… + Rotate the selected pages counterclockwise หมุนหน้าที่เลือกทวนเข็มนาฬิกา + Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา + Extract the selected pages… แยกหน้าที่เลือกออก… + Delete the selected pages ลบหน้าที่เลือก diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bcdfe9a90e..0d07fcc760 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2536,3 +2536,11 @@ animation effects Off Kapalı # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Bugün @plural {n} reply|{n} replies {n} yanıt + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sayfa araçları + Insert pages from a file after the selection… Seçimin ardından dosyadan sayfa ekle… + Rotate the selected pages counterclockwise Seçili sayfaları saat yönünün tersine döndür + Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür + Extract the selected pages… Seçili sayfaları ayıkla… + Delete the selected pages Seçili sayfaları sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 599b4b63af..7531c9a753 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2562,3 +2562,11 @@ animation effects Off Вимкнено # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сьогодні @plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Інструменти сторінок + Insert pages from a file after the selection… Вставити сторінки з файлу після виділення… + Rotate the selected pages counterclockwise Повернути вибрані сторінки проти годинникової стрілки + Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою + Extract the selected pages… Витягнути вибрані сторінки… + Delete the selected pages Видалити вибрані сторінки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 048f78f624..d9c375a6e2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2807,3 +2807,11 @@ key Space Space # AI-assisted additions for restoring the last view (#1767) (2026-10-16); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Khôi phục chế độ xem gần nhất (trang, thu phóng và hiển thị trang) khi mở lại tài liệu @plural {n} reply|{n} replies {n} câu trả lời + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Công cụ trang + Insert pages from a file after the selection… Chèn trang từ tệp sau vùng chọn… + Rotate the selected pages counterclockwise Xoay các trang đã chọn ngược chiều kim đồng hồ + Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ + Extract the selected pages… Trích xuất các trang đã chọn… + Delete the selected pages Xóa các trang đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7ea3ca941c..e2b1f78974 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2504,3 +2504,11 @@ animation effects Off 关 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @plural {n} reply|{n} replies {n} 条回复 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 页面工具 + Insert pages from a file after the selection… 在所选内容后插入文件中的页面… + Rotate the selected pages counterclockwise 逆时针旋转所选页面 + Rotate the selected pages clockwise 顺时针旋转所选页面 + Extract the selected pages… 提取所选页面… + Delete the selected pages 删除所选页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b34cb787ca..ecb5ff6231 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2490,3 +2490,11 @@ animation effects Off 關 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @plural {n} reply|{n} replies {n} 則回覆 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 頁面工具 + Insert pages from a file after the selection… 在所選內容後插入檔案中的頁面… + Rotate the selected pages counterclockwise 逆時針旋轉所選頁面 + Rotate the selected pages clockwise 順時針旋轉所選頁面 + Extract the selected pages… 擷取所選頁面… + Delete the selected pages 刪除所選頁面 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0300afd461..49b7bb81b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3672,6 +3672,9 @@ impl eframe::App for PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.window_title = title; } + // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on + // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. + ctx.accesskit_node_builder(egui::accesskit_root_id(), |root| root.set_label(self.window_title.as_str())); if self.full_screen && self.active.is_some() { // Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc // or ⌘L to leave). diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index fbca7bb08e..5340cef393 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -117,8 +117,19 @@ impl Default for MarksDraft { const PAGE_FORMATS: [&str; 5] = ["<<1>>", "<<1 of n>>", "<<1/n>>", "<>", "<>"]; // New formats go last: `date_format` is an index into this list. -const DATE_FORMATS: [&str; 8] = - ["<>", "<>", "<>", "<>", "<>", "<>", "<>", "<>"]; +const DATE_FORMATS: [&str; 11] = [ + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", +]; const BOX_NAMES: [&str; 6] = ["Left Header Text", "Center Header Text", "Right Header Text", "Left Footer Text", "Center Footer Text", "Right Footer Text"]; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 09e99ab7ac..535ba2e083 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -730,6 +730,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() { close = true; } + if panel == RightPanel::Pages + && let Some(c) = page_tools(ui, !modal && view.panel_drag.is_none() && bm_editable) + { + panel_command = Some(c); + } // Right to left: close, "…", filter, search (Acrobat's order left to right). if panel == RightPanel::Comments { panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden()); @@ -913,42 +918,87 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { empty(ui, &t, "paperclip", "This document has no attachments."); } for (ai, a) in info.attachments.iter().enumerate() { - egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { - ui.horizontal(|ui| { - ui.add(icons::image("paperclip", 16.0, t.icon)); - ui.vertical(|ui| { - ui.set_width(ui.available_width() - 70.0); - ui.add(egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate()); - let mut meta = a.size.map(human_size).unwrap_or_default(); - if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source { - let on_page = crate::i18n::fmt( - tl!("on page {label}"), - &[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))], + let is_pdf = a.name.to_lowercase().ends_with(".pdf"); + // Only an attachment of the document itself can be deleted here. + let deletable = match &a.source { + pdfcraft_render::AttachmentSource::Document { key } if attachable => Some(key), + _ => None, + }; + // The whole row answers a double-click and a right-click (#3466), below + // its buttons, which still take their own clicks. + let mut buttons: Vec = Vec::new(); + let row = ui.scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(icons::image("paperclip", 16.0, t.icon)); + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 70.0); + // Not selectable, so a click on the name or the details reaches the row. + ui.add( + egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate().selectable(false), ); - meta = format!("{meta} · {on_page}"); - } - if let Some(d) = &a.description { - meta = format!("{meta} · {d}"); - } - ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate()); + let mut meta = a.size.map(human_size).unwrap_or_default(); + if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source { + let on_page = crate::i18n::fmt( + tl!("on page {label}"), + &[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))], + ); + meta = format!("{meta} · {on_page}"); + } + if let Some(d) = &a.description { + meta = format!("{meta} · {d}"); + } + ui.add( + egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate().selectable(false), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let save = icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")); + if save.clicked() { + attachment_action = Some((ai, false)); + } + buttons.push(save); + if is_pdf { + let open = icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")); + if open.clicked() { + attachment_action = Some((ai, true)); + } + buttons.push(open); + } + if let Some(key) = deletable { + let delete = icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")); + if delete.clicked() { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + } + buttons.push(delete); + } + }); }); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")).clicked() { - attachment_action = Some((ai, false)); - } - if a.name.to_lowercase().ends_with(".pdf") - && icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")).clicked() - { - attachment_action = Some((ai, true)); - } - if let pdfcraft_render::AttachmentSource::Document { key } = &a.source - && attachable - && icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")).clicked() - { - panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); - } - }); - }); + }) + }); + // A double-click opens a PDF in a new tab, as Acrobat opens an attachment; + // any other file is saved, since PdfCraft opens only PDFs. + if row.response.double_clicked() { + attachment_action = Some((ai, is_pdf)); + } + // A right-click on one of the row's buttons is a right-click on the row: + // the union keeps the row's id, so it is one menu either way. + let menu = buttons.into_iter().fold(row.response.clone(), |row, button| row | button); + menu.context_menu(|ui| { + if is_pdf && ui.button(tl!("Open in a new tab")).clicked() { + attachment_action = Some((ai, true)); + ui.close(); + } + if ui.button(tl!("Save attachment…")).clicked() { + attachment_action = Some((ai, false)); + ui.close(); + } + if let Some(key) = deletable + && ui.button(tl!("Delete attachment")).clicked() + { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + ui.close(); + } }); } } @@ -1290,6 +1340,38 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte } } +/// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, +/// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its +/// command on the selected pages (the current page when none is), so it does what Pages ▸ … +/// does; the items say so, apart from the thumbnails' own menu. `enabled` is false while the +/// document may not be assembled or a dialog is open. Returns the command a click asks for. +fn page_tools(ui: &mut egui::Ui, enabled: bool) -> Option<&'static str> { + let tools: [(&'static str, &str); 6] = [ + ("page.insert_blank", tl!("Insert a blank page after the selection")), + ("page.insert", tl!("Insert pages from a file after the selection…")), + ("page.rotate_ccw", tl!("Rotate the selected pages counterclockwise")), + ("page.rotate", tl!("Rotate the selected pages clockwise")), + ("page.extract", tl!("Extract the selected pages…")), + ("page.delete", tl!("Delete the selected pages")), + ]; + let mut picked = None; + ui.add_enabled_ui(enabled, |ui| { + let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Page tools")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(260.0); + for (id, label) in tools { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + let item = egui::Button::image_and_text(icons::image(spec.icon, 16.0, ui.visuals().text_color()), label); + if ui.add(item).clicked() { + picked = Some(id); + ui.close(); + } + } + }); + }); + picked +} + /// Size controls stay above the scroll area, so they remain reachable on long documents. fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) { let before = view.thumbnail_zoom(); diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index d02ea5dc8c..70df814222 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -248,20 +248,41 @@ pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { /// After `desktop_grab`, add the primary button's release to the next input, unless it brings one. /// The release goes before the input's first press (the next resize or move, or a click), which -/// egui would otherwise end straight away; a release after that press is the press's own. +/// egui would otherwise end straight away; a release after that press is the press's own. It also +/// goes before the pointer leaving, as a button event would bring the gone pointer back; a release +/// the input brings after the pointer left is the grab's own, and moves there instead. pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) { if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() { return; } - let first = raw.events.iter().enumerate().find_map(|(n, e)| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some((n, *pressed)), - _ => None, - }); - let at = match first { - Some((_, false)) => return, - Some((n, true)) => n, - None => raw.events.len(), - }; + let mut at = raw.events.len(); + let mut gone = None; + let mut brought = None; + for (n, e) in raw.events.iter().enumerate() { + match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => { + brought = Some(n); + break; + } + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } => { + at = n; + break; + } + egui::Event::PointerGone => { + gone.get_or_insert(n); + } + _ => {} + } + } + if let Some(n) = brought { + // `gone` was found before `n`, so both are in range. + if let Some(g) = gone { + let release = raw.events.remove(n); + raw.events.insert(g, release); + } + return; + } + let at = gone.unwrap_or(at); let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); raw.events.insert(at, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); } @@ -389,4 +410,52 @@ mod tests { frame(vec![], Some(egui::ViewportCommand::StartDrag)); assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]); } + + #[test] + fn the_release_goes_before_the_pointer_leaving() { + let ctx = egui::Context::default(); + let frame = |events: Vec, grab: bool| { + let mut raw = egui::RawInput { events, ..Default::default() }; + super::release_after_desktop_grab(&ctx, &mut raw); + let order = raw.events.clone(); + ctx.run_ui(raw, |ui| { + if grab { + super::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); + } + }) + .textures_delta + .clear(); + order + }; + let release = |e: &egui::Event| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. }); + let press = egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }; + // On Wayland the input after a move brings only that the pointer left. + frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), press], true); + let order = frame(vec![egui::Event::PointerGone], false); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "{order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + assert!(!ctx.input(|i| i.pointer.primary_down())); + + // A release after the pointer left is the one the desktop kept, not to be doubled. + frame(vec![], true); + let order = frame( + vec![ + egui::Event::PointerGone, + egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ], + false, + ); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "moved, not doubled: {order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + } } diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs new file mode 100644 index 0000000000..1a66723bb9 --- /dev/null +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -0,0 +1,189 @@ +//! An attachment's row in the Attachments panel answers a double-click and a right-click, as +//! in Acrobat (#3466), while its buttons keep taking their own clicks. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A one-page PDF with no embedded files, to attach to. +const INNER: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A one-page PDF with two embedded files: `inner.pdf` (a PDF) and `notes.txt`. +fn attachments_pdf() -> Vec { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(inner.pdf) 5 0 R (notes.txt) 7 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(), + [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(), + b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(), + b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(), + b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(), + ]; + 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 +} + +/// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a +/// second so two clicks a frame apart make a double-click. +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap(); + app.set_option("panel", "attachments").unwrap(); + app + }); + h.run_steps(4); + h +} + +/// A folder of its own for one test's saved file, removed afterwards. +struct Dir(std::path::PathBuf); + +impl Dir { + fn new(test: &str) -> Self { + let path = std::env::temp_dir().join(format!("pdfcraft-attachment-row-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Two pointer clicks a frame apart on `label` (an accessibility click is never a double-click). +fn double_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + let at = row_name(h, label); + h.hover_at(at); + h.run_steps(1); + for _ in 0..2 { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(1); + } + h.run_steps(3); +} + +fn right_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + let at = row_name(h, label); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn double_clicking_a_pdf_attachment_opens_it_in_a_new_tab() { + let mut h = harness(); + assert_eq!(h.state().views.len(), 1); + double_click(&mut h, "inner.pdf"); + assert_eq!(h.state().views.len(), 2, "the attached PDF opens in a tab of its own"); +} + +#[test] +fn double_clicking_another_attachment_saves_it() { + let dir = Dir::new("double"); + let target = dir.0.join("saved notes.txt"); + let mut h = harness(); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + double_click(&mut h, "notes.txt"); + assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "PdfCraft opens only PDFs, so it saves the rest"); + assert_eq!(h.state().views.len(), 1, "nothing opens"); +} + +#[test] +fn the_right_click_menu_opens_saves_and_deletes() { + let dir = Dir::new("menu"); + let target = dir.0.join("from menu.pdf"); + let mut h = harness(); + let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count(); + // The rows have Save and Delete buttons each, and the PDF's row an Open button. + assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (2, 1, 2)); + // A text file's menu has Save and Delete, no Open. + right_click(&mut h, "notes.txt"); + assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (3, 1, 3)); + h.key_press(egui::Key::Escape); + h.run_steps(3); + // The PDF's menu: Save writes the PDF, not the text file whose row comes last. + right_click(&mut h, "inner.pdf"); + assert_eq!(count(&h, "Open in a new tab"), 2, "the PDF's menu offers Open"); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + h.get_all_by_label("Save attachment…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read(&target).unwrap(), INNER, "Save from inner.pdf's menu"); + right_click(&mut h, "inner.pdf"); + h.get_all_by_label("Open in a new tab").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2, "Open from the menu"); + h.state_mut().active = Some(0); + h.run_steps(3); + // Delete from inner.pdf's menu leaves notes.txt, whose row's button would delete itself. + right_click(&mut h, "inner.pdf"); + h.get_all_by_label("Delete attachment").last().unwrap().click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().map(|a| a.name.as_str()).collect::>(), ["notes.txt"], "Delete from the menu"); + assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit"); +} + +#[test] +fn the_row_buttons_still_take_their_own_clicks() { + let dir = Dir::new("buttons"); + let target = dir.0.join("from button.txt"); + let mut h = harness(); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + // The rows list inner.pdf first, then notes.txt: the second Save button is notes.txt's. + h.get_all_by_label("Save attachment…").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "the Save button under the row's click area"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2, "the Open button"); +} + +/// CodeRabbit on #4513: a right-click on one of a row's buttons opens that row's menu too, as a +/// right-click anywhere on the row does. +#[test] +fn a_right_click_on_a_row_button_opens_the_row_menu() { + let mut h = harness(); + let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count(); + // inner.pdf's row comes first: its Save button is the first one. + let at = h.get_all_by_label("Save attachment…").next().expect("inner.pdf's Save button").rect().center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(count(&h, "Open in a new tab"), 2, "inner.pdf's menu, with Open, is open"); + assert_eq!(count(&h, "Save attachment…"), 3); +} + +/// Where the attachment named `label` shows in the panel: the lowest text with that name, below +/// a tab of the same name once the attachment is open. +fn row_name(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 { + h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).expect("the attachment is listed").center() +} diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0d7253707e..0353a7d2b3 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -609,6 +609,39 @@ fn dragged_text_follows_the_pointer_before_release() { assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); } +/// #4464: what a dragged text box covered in its first place stays there; only the text follows +/// the pointer. The page has a red square under the text's first place and nothing elsewhere. +#[test] +fn dragged_text_leaves_what_it_covered_behind() { + let _gpu = gpu(); + let content = "1 0 0 rg 20 60 240 70 re f"; + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut h = harness_bytes(pdf.into_bytes()); + let (rect, from, to, end) = drag_added_text(&mut h); + assert!(rect[0] > 20.0 && rect[2] < 260.0 && rect[1] > 60.0 && rect[3] < 130.0, "the text starts on the red square: {rect:?}"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/added-text-drag-over-red.png")).unwrap(); + } + let pixels = h.render().unwrap(); + let red = |r: egui::Rect| { + let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); + (y0..y1.min(pixels.height())) + .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) + .filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 200 && g < 80 && b < 80)) + .count() + }; + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20 && old == 0, "the text follows the pointer ({old} old, {new} new)"); + assert!(red(from) > 100, "the red square stays where the text was ({} red pixels)", red(from)); + assert_eq!(red(to), 0, "and doesn't come along with the text"); + h.drop_at(end); + h.run_steps(3); + assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); +} + /// After release the text stays in its new place while the page is re-rendered with the move, /// instead of jumping back to where it was for those frames. #[test] diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ca96e8bba7..12fc0cd92e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -21,6 +21,7 @@ mod accessibility; mod actions; mod animate; mod attach_picker_target; +mod attachment_row; mod bookmark_position; mod bookmarks; mod bulk_fields; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index b825968ff9..01e733e4d7 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1468,6 +1468,52 @@ fn required_radio_buttons_get_a_round_red_border() { assert!(!stroke(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7)); } +/// Two required check boxes: the first laid out for a round XFA check button (`/PCXfaRound`), the +/// second an ordinary square one. +const ROUND_CHECKS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (round) /V /Off /AS /Off /Rect [50 300 90 340] /PCXfaRound true /P 3 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (square) /V /Off /AS /Off /Rect [150 300 190 340] /P 3 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// With fields highlighted, a round XFA check box gets a round border, as a radio button does, +/// while an ordinary check box keeps a square one. +#[test] +fn round_check_boxes_get_a_round_highlight() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("checks.pdf", None, ROUND_CHECKS.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("fields", "on").unwrap(); + app + }); + crate::support::settle(&mut h); + let r = rect(&h, 0).expect("page 1"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().expect("renders"); + let at = |x: f32, y: f32| { + let p = egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0)); + *img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32) + }; + // The red of the required border, or its antialiased edge over the field tint. + let stroke = |p: image::Rgba| p[0] > 180 && p[2] < 140 && i16::from(p[0]) - i16::from(p[2]) > 80; + assert!([0.05_f32, 0.2].into_iter().any(|dx| stroke(at(50.0 + dx, 320.0))), "the round box's left edge is red: {:?}", at(50.2, 320.0)); + // Near the top of the left edge: outside the circle, on a square's border. + assert!(!stroke(at(50.25, 339.0)), "the round box's corner, outside the circle, is not: {:?}", at(50.25, 339.0)); + assert!( + [0.1_f32, 0.25, 0.4].into_iter().any(|dx| stroke(at(150.0 + dx, 339.0))), + "the square box's is: {:?} {:?}", + at(150.1, 339.0), + at(150.25, 339.0) + ); +} + /// Acrobat's Highlight existing fields is a preference, not a per-document choice: turned off /// once, the next document (and the next session) opens with fields plain. New settings start /// with it on. @@ -2111,3 +2157,101 @@ fn full_screen_clicks_turn_pages_whatever_tool_was_on() { assert!(!h.state().full_screen); assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back"); } + +/// #4415: the Pages panel's Page tools menu. Each tool acts on the selected pages (the current +/// page when none is): rotate, delete and insert a blank page here, as Pages ▸ … does. +#[test] +fn the_pages_panel_tools_act_on_the_selected_pages() { + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let rotations = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::>(); + // The panel's Page tools menu, on the right-hand side of the window. + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + h.state_mut().views[0].select_pages(&[1, 2]); + h.run_steps(2); + tool(&mut h, "Rotate the selected pages clockwise"); + assert_eq!(rotations(&h), [0, 90, 90], "pages 2 and 3, the selection"); + tool(&mut h, "Rotate the selected pages counterclockwise"); + assert_eq!(rotations(&h), [0, 0, 0]); + h.state_mut().views[0].select_pages(&[0]); + h.run_steps(2); + tool(&mut h, "Delete the selected pages"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "page 1 deleted"); + tool(&mut h, "Insert a blank page after the selection"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "each is one undo step"); +} + +/// CodeRabbit on #4583: the Page tools menu's file items work from the menu too. Insert pages +/// from a file puts the picked file's pages after the selection; Extract the selected pages +/// opens Extract for the selection and writes it. +#[test] +fn the_pages_panel_tools_insert_from_a_file_and_extract() { + let dir = std::env::temp_dir().join(format!("pdfcraft-page-tools-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let more = dir.join("more.pdf"); + std::fs::write(&more, PAGES).unwrap(); + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.len(); + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + assert_eq!(count(&h), 3); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().pick_override = Some(vec![more.to_string_lossy().into_owned()]); + tool(&mut h, "Insert pages from a file after the selection…"); + h.run_steps(3); + assert_eq!(count(&h), 6, "the picked file's three pages are inserted"); + + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().views[0].select_pages(&[1, 2]); + tool(&mut h, "Extract the selected pages…"); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Extract), "Extract opens for the selection"); + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.get_all_by_label("Extract").last().unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 2, "the two selected pages, each a file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The Pages panel's Page tools are greyed out on a document whose security doesn't allow +/// page changes: the menu doesn't open, and nothing changes. +#[test] +fn the_pages_panel_tools_are_off_without_page_editing_permission() { + use egui_kittest::kittest::NodeT; + let mut h = harness(&[("panel", "pages")]); + { + let app = h.state_mut(); + app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { + permissions_password: Some("owner".into()), + changes: pdfcraft_engine::Changes::None, + ..Default::default() + })); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap(); + } + h.run_steps(6); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + assert!(!h.state().session.get(id).unwrap().allows_assembly()); + let tools = h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools"); + assert!(tools.accesskit_node().is_disabled(), "Page tools is greyed out"); + tools.click(); + h.run_steps(3); + assert!(h.query_by_label("Delete the selected pages").is_none(), "its menu doesn't open"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 7795e05134..915c47b41a 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -2087,3 +2087,26 @@ fn go_to_page_brings_the_page_into_view_horizontally() { let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect()); assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}"); } + +/// #4614: the accessible window is named like the native one, and follows it when the active +/// document changes, so a screen reader can find the window by its title. +#[test] +fn the_accessible_window_has_the_window_title_as_its_name() { + use egui_kittest::kittest::NodeT as _; + let mut h = harness(); + h.run_steps(2); + let name = |h: &Harness<'static, PdfCraftApp>| h.root().accesskit_node().label().map(|l| l.to_string()); + assert_eq!(name(&h).as_deref(), Some("b.pdf — PdfCraft")); + { + use pdfcraft_engine::Edit; + let s = h.state_mut(); + let id = s.views[s.active.unwrap()].id; + s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap(); + let mut v = s.session.get(id).unwrap().initial_view(); + v.display_title = true; + s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); + } + h.run_steps(2); + assert_eq!(name(&h).as_deref(), Some("Quarterly report — PdfCraft")); + assert_eq!(name(&h), Some(h.state().window_title.clone())); +} diff --git a/crates/ui-egui/tests/snapshots/panel_pages.txt b/crates/ui-egui/tests/snapshots/panel_pages.txt index 4e746f1006..7f432057d9 100644 --- a/crates/ui-egui/tests/snapshots/panel_pages.txt +++ b/crates/ui-egui/tests/snapshots/panel_pages.txt @@ -1,6 +1,7 @@ Label = "Pages" Label = "2" Button "Close" [off] +Button "Page tools" [off] Label = "Thumbnail size" Label = "100%" Button "Smaller thumbnails" [off] diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 23a6ef0d0e..2d08a74bb2 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -38,8 +38,11 @@ that warning and keeps the existing layout and accepted widget values. XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate` format and keystroke actions from the picture clause), check boxes and radio groups (from `exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their - scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps - its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out + scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as + fields under one for each step above it) because other viewers lay a dynamic form out again + and find a signature by that name. A box whose path, or a part of it, the file already gives + another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to + generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them. - **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data @@ -82,6 +85,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi | `/PCSom` | generated fields | the field's SOM path, so values go back to the data | | `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again | | `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written | + | `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds | | `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` | ## API sketch diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index b76b3efa1a..8f6e0f7ee8 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec { assemble(objs) } +/// A static XFA form of `n` text fields `form1[0].page1[0].f[0]` on one page, whose values +/// (`v`) are only in its datasets, as when another viewer filled it in. The first field is +/// read-only. +pub fn static_shell_many(n: usize) -> Vec { + let data: String = (0..n).map(|i| format!("v{i}")).collect(); + let xdp = format!( + "{data}" + ); + // Objects 1–7, then the widgets from 8. + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 8)).collect(); + let kids = kids.join(" "); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [{kids}] >>").into_bytes(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), + format!("<< /T (page1[0]) /Parent 6 0 R /Kids [{kids}] >>").into_bytes(), + ]; + for i in 0..n { + let y = 760 - (i % 50) * 14; + let x = 36 + (i / 50) * 110; + // The first field is read-only: its data value is not taken. + let flags = if i == 0 { " /Ff 1" } else { "" }; + objs.push( + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}[0]){flags} /Parent 7 0 R /Rect [{x} {y} {} {}] /P 3 0 R /DA (/Helv 9 Tf 0 g) >>", + x + 100, + y + 12 + ) + .into_bytes(), + ); + } + assemble(objs) +} + /// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a /// trailer whose root is object 1. fn assemble(objs: Vec>) -> Vec { diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 41a250a546..7e63218c90 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -99,7 +99,7 @@ pub use data::{ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; -pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index fd80949e8f..23e21fc331 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -2,7 +2,7 @@ //! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance //! streams are left empty here; the forms layer regenerates them from the field dictionaries. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{literal, win_ansi}; @@ -21,6 +21,10 @@ pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout"; /// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the /// field's SOM path. pub const CLICK_KEY: &[u8] = b"PCXfaClick"; +/// Private key on the fields generated to name a signature field by its SOM path, one for each +/// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the +/// fields they hold. +pub const NODE_KEY: &[u8] = b"PCXfaNode"; /// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT /// markers). `None` for anything else. @@ -44,7 +48,7 @@ fn hex32(h: &[u8; 32]) -> Vec { fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { let mut out = [0u8; 32]; - if s.len() != 64 { + if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { return None; } for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { @@ -264,6 +268,11 @@ struct Emitter<'a> { warnings: Vec, /// The signatures the file holds, by the SOM path of the field signed. signed: HashMap, + /// The full names of the fields the file came with, which a generated name must not repeat. + taken: HashSet, + /// The fields above the signature fields named by their SOM paths, by the path they name: + /// each one's object and the fields it holds. + nodes: HashMap)>, } /// A signature field of the file that has been signed. @@ -279,13 +288,16 @@ const MAX_FIELDS_SEARCHED: usize = 20_000; /// Deepest field nesting followed. const MAX_FIELD_DEPTH: usize = 32; -/// The signed signature fields the document came with, by full name. Signing applications name -/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a -/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read. -fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { +/// The fields the document came with: the full name of each, and the signed signature fields by +/// SOM path. Signing applications name a dynamic form's fields by their SOM path +/// (`form1[0].page1[0].sign[0]`), which is how a laid-out signature box finds its signature. A +/// field an earlier layout pass generated gives no name, but a signature on it is kept, by its +/// `/PCSom`, so a new layout keeps the box signed. +fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, HashSet) { let mut out = HashMap::new(); - let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out }; - let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out }; + let mut names = HashSet::new(); + let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; + let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; let mut stack: Vec<(Object, String, bool, usize)> = fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); let mut seen = 0usize; @@ -296,7 +308,22 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { } let field = doc.resolve(&field); let Some(d) = field.as_dict() else { continue }; + if d.contains(NODE_KEY) { + // Generated to name a signature field: only what it holds is looked at. + if depth < MAX_FIELD_DEPTH { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1))); + } + continue; + } if d.contains(SOM_KEY) { + let som = d.get(SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text())); + if d.name(b"FT") == Some(b"Sig") + && let Some(som) = som + && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) + { + out.entry(som).or_insert(Signed { value: value.clone(), appearance: d.get(b"AP").cloned() }); + } continue; } let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { @@ -304,6 +331,9 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { Some(t) => format!("{prefix}.{t}"), None => prefix, }; + if !name.is_empty() { + names.insert(name.clone()); + } let is_sig = match d.name(b"FT") { Some(ft) => ft == b"Sig", None => parent_is_sig, @@ -318,7 +348,7 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); } } - out + (out, names) } impl Emitter<'_> { @@ -363,6 +393,9 @@ impl Emitter<'_> { ff |= 1; } let mut top_level = true; + // The SOM path of the generated field above this one, for a signature field named by its + // SOM path. + let mut under: Option = None; match &w.kind { WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => { d.set(b"FT".to_vec(), Object::name("Tx")); @@ -464,6 +497,16 @@ impl Emitter<'_> { } ff |= 1; } + if let Some((above, last)) = self.som_steps(&w.som) + && let Some(parent) = self.node(&above) + { + // Named by its SOM path, as fields under one for each step above it: other + // viewers lay a dynamic form out again and find a signature by that name. + d.set(b"T".to_vec(), Object::String(PdfString::text(&last))); + d.set(b"Parent".to_vec(), Object::Ref(parent)); + top_level = false; + under = Some(above.join(".")); + } } WidgetKind::Image => { // A push button with nothing drawn: the forms layer treats one so marked as an @@ -574,6 +617,12 @@ impl Emitter<'_> { if top_level { self.fields.push(r); self.field_count += 1; + } else if let Some(path) = under { + if let Some((_, kids)) = self.nodes.get_mut(&path) { + kids.push(r); + } + self.taken.insert(w.som.clone()); + self.field_count += 1; } else if let WidgetKind::Radio { group, .. } = &w.kind && let Some((_, kids)) = self.radio_groups.get_mut(group) { @@ -582,6 +631,60 @@ impl Emitter<'_> { Ok(Some(r)) } + /// The steps of SOM path `som` above the last, and the last, when a field can be named by the + /// path: two to [`MAX_FIELD_DEPTH`] steps, none empty, and no field of the file or of this + /// pass already named by the path or by a part of it. `None` keeps the field's own name. + fn som_steps(&self, som: &str) -> Option<(Vec, String)> { + let steps: Vec<&str> = som.split('.').collect(); + if steps.len() < 2 || steps.len() > MAX_FIELD_DEPTH || steps.iter().any(|s| s.is_empty()) { + return None; + } + for i in 1..=steps.len() { + let path = steps.get(..i)?.join("."); + // A part already generated by this pass is shared; one the file has is not touched. + let generated = i < steps.len() && self.nodes.contains_key(&path); + if !generated && self.taken.contains(&path) { + return None; + } + } + let (last, above) = steps.split_last()?; + Some((above.iter().map(|s| s.to_string()).collect(), last.to_string())) + } + + /// The generated field named by the SOM path of `steps`, made (with those above it) when this + /// pass has none yet; the topmost goes into the AcroForm's fields. `None` for no steps. + fn node(&mut self, steps: &[String]) -> Option { + let mut parent: Option<(ObjRef, String)> = None; + for step in steps { + let path = match &parent { + Some((_, above)) => format!("{above}.{step}"), + None => step.clone(), + }; + let r = match self.nodes.get(&path) { + Some((r, _)) => *r, + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), Object::String(PdfString::text(step))); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + d.set(NODE_KEY.to_vec(), Object::Bool(true)); + if let Some((p, _)) = &parent { + d.set(b"Parent".to_vec(), Object::Ref(*p)); + } + let r = self.doc.add(Object::Dict(d)); + match parent.as_ref().and_then(|(_, above)| self.nodes.get_mut(above)) { + Some((_, kids)) => kids.push(r), + None => self.fields.push(r), + } + self.nodes.insert(path.clone(), (r, Vec::new())); + self.taken.insert(path.clone()); + r + } + }; + parent = Some((r, path)); + } + parent.map(|(r, _)| r) + } + /// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs /// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning, /// for anything else. @@ -908,6 +1011,9 @@ fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> { out } +/// Write laid-out `form` into `doc`: its pages replace the document's, its widgets join the +/// AcroForm's fields (a box the file holds a signature for keeps it), and the AcroForm is marked +/// laid out. What was written, with any warnings, comes back. pub fn write_form(doc: &mut Document, form: &Form) -> Result { let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?; let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?; @@ -925,7 +1031,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result }; // Pictures embedded by earlier passes, from the pages this pass replaces. let images = earlier_images(doc, pages_ref); - let signed = signed_fields(doc, &catalog); + let (signed, taken) = existing_fields(doc, &catalog); let mut em = Emitter { doc, images, @@ -935,7 +1041,17 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result field_count: 0, warnings: Vec::new(), signed, + taken, + nodes: HashMap::new(), }; + // Every name a field of this pass takes at the top, wherever and in whatever order it is + // written: a signature field named by its SOM path must not repeat one. + for w in form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }) { + em.taken.insert(match &w.kind { + WidgetKind::Radio { group, .. } => group.clone(), + _ => w.name.clone(), + }); + } let mut kids = Vec::new(); for page in &form.pages { kids.push(Object::Ref(em.page(page, pages_ref)?)); @@ -950,6 +1066,10 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result } })?; } + let nodes: Vec<(ObjRef, Vec)> = em.nodes.values().cloned().collect(); + for (node, kids) in nodes { + em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?; + } let Emitter { doc, fields, field_count, warnings, .. } = em; let count = kids.len(); doc.update_dict(pages_ref, |d| { @@ -1174,7 +1294,7 @@ pub fn strip(doc: &mut Document) -> Result { out.image_fields += 1; } let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); if !private.is_empty() { out.field_keys += private.len(); doc.update_dict(r, |d| { @@ -1294,3 +1414,20 @@ pub fn existing_layout(doc: &Document) -> Option { pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] } + +#[cfg(test)] +mod tests { + use super::parse_hex32; + + #[test] + fn parse_hex32_rejects_non_hex_digits() { + for prefix in ["+f", "-f", " f"] { + let mut s = prefix.as_bytes().to_vec(); + s.resize(64, b'0'); + assert!(parse_hex32(&s).is_none(), "{prefix:?}"); + } + let mut ok = b"Ff".to_vec(); + ok.resize(64, b'0'); + assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff)); + } +} diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 80fb1db33c..6946aa9b1c 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -586,13 +586,31 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result if form.pages.is_empty() { return Err(XfaError::Malformed("the template laid out to no pages".into())); } - // The AcroForm keeps only the fields PdfCraft did not generate. Any generated fields edited in - // this overlay (along with their appearance streams and widget kids) are freed so a replaced - // password widget's value or masked appearance cannot stay in the overlay. + // The fields an earlier pass generated give way to the new layout's, after it is written: it + // keeps a signature one of them holds. The AcroForm keeps every field PdfCraft did not generate. let Some(root) = doc.root() else { return Err(XfaError::NotXfa) }; - if let Some((acro_ref, mut acro)) = acroform(doc) { - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let mut kept = Vec::new(); + let top = |doc: &Document| -> Vec { + acroform(doc).and_then(|(_, acro)| acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned())).unwrap_or_default() + }; + let generated: std::collections::HashSet = top(doc) + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().is_some_and(|d| d.contains(crate::pdf::SOM_KEY) || d.contains(crate::pdf::NODE_KEY))) + .collect(); + let written = crate::pdf::write_form(doc, &form)?; + if !generated.is_empty() + && let Some((acro_ref, mut acro)) = acroform(doc) + { + let kept: Vec = top(doc).into_iter().filter(|f| f.as_ref().is_none_or(|r| !generated.contains(&r))).collect(); + acro.set(b"Fields".to_vec(), Object::Array(kept)); + match acro_ref { + Some(r) => doc.set(r, Object::Dict(acro)), + None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, + } + // A generated field edited in this overlay is freed with its appearance and widget kids, + // so a replaced password widget's value or masked appearance cannot stay in the overlay. + // Signature fields, and the fields that name them by their SOM path, stay: the new + // layout's signature field took their value and appearance. let free_ap = |doc: &mut Document, d: &Dict| { if let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) && let Some(n) = ap.get(b"N").and_then(Object::as_ref) @@ -601,36 +619,28 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result doc.free(n); } }; - for f in fields { - if let Some(r) = f.as_ref() - && let Some(d) = doc.get(r).as_dict().cloned() - && d.contains(crate::pdf::SOM_KEY) - { - free_ap(doc, &d); - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - for k in kids.iter().filter_map(Object::as_ref) { - if let Some(kd) = doc.get(k).as_dict().cloned() { - free_ap(doc, &kd); - } - if doc.is_edited(k.num) { - doc.free(k); + let signature = |d: &Dict| d.name(b"FT") == Some(b"Sig".as_slice()) || d.contains(crate::pdf::NODE_KEY); + for r in generated { + let Some(d) = doc.get(r).as_dict().cloned().filter(|d| !signature(d)) else { continue }; + free_ap(doc, &d); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if let Some(kd) = doc.get(k).as_dict().cloned() { + if signature(&kd) { + continue; } + free_ap(doc, &kd); + } + if doc.is_edited(k.num) { + doc.free(k); } } - if doc.is_edited(r.num) { - doc.free(r); - } - } else { - kept.push(f); + } + if doc.is_edited(r.num) { + doc.free(r); } } - acro.set(b"Fields".to_vec(), Object::Array(kept)); - match acro_ref { - Some(r) => doc.set(r, Object::Dict(acro)), - None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, - } } - let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); if written.usage_rights { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index cb106de841..39a1c93483 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1508,6 +1508,8 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { .collect() } +/// Choice lists, a password, a signature and pictures in the template become their kinds of +/// widget, and their AcroForm fields and image XObjects once written. #[test] fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { let form = layout_xml(&fields_template("")).unwrap(); @@ -1570,7 +1572,11 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string"); assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box"); assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field"); - assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice())); + // The signature field is named by its SOM path, under a generated field for each step above it. + assert!(fields.iter().all(|(name, _)| name != "sign")); + let named = full_names(&doc); + let sign = named.iter().find(|(name, _)| name == "form[0].page1[0].sign[0]").map(|(_, d)| d).expect("named by its SOM path"); + assert_eq!(sign.name(b"FT"), Some(b"Sig".as_slice())); let streams: Vec = doc .object_numbers() .into_iter() @@ -3009,6 +3015,213 @@ fn a_zero_caption_reserve_sizes_the_caption_as_none_does() { assert!((x_of("withZero") - x_of("without")).abs() < 0.01, "as without a reserve: {} {}", x_of("withZero"), x_of("without")); } +/// The terminal fields by full name, down the field tree. +fn full_names(doc: &Document) -> Vec<(String, Dict)> { + let root = doc.get(doc.root().unwrap()); + let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let mut todo: Vec<(Object, String)> = + doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().map(|f| (f.clone(), String::new())).collect(); + let mut out = Vec::new(); + while let Some((f, prefix)) = todo.pop() { + let d = doc.resolve(&f).as_dict().unwrap().clone(); + let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())); + let name = match t { + Some(t) if prefix.is_empty() => t, + Some(t) => format!("{prefix}.{t}"), + None => prefix, + }; + match d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + Some(kids) if kids.iter().any(|k| doc.resolve(k).as_dict().is_some_and(|k| k.contains(b"T"))) => { + todo.extend(kids.into_iter().map(|k| (k, name.clone()))); + } + _ => out.push((name, d)), + } + } + out +} + +/// A laid-out signature field is named by its SOM path, under a generated field for each step +/// above it, as other viewers lay a dynamic form out again and find its signature by that name. +/// Boxes in one subform share those fields; a new layout replaces them rather than adding more; +/// taking the XFA out leaves them as ordinary fields. +#[test] +fn signature_fields_are_named_by_their_som_path() { + let xdp = r##" + + + +"##; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert_eq!(report.fields, 4, "name and the three signature fields"); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let check = |doc: &Document| { + let top: Vec = pdfcraft_forms_free_fields(doc).into_iter().map(|(name, _)| name).collect(); + assert_eq!(top.iter().filter(|n| *n == "form[0]").count(), 1, "one field for the root subform: {top:?}"); + assert!(top.iter().any(|n| n == "name"), "other fields keep their names: {top:?}"); + let named = full_names(doc); + for som in ["form[0].body[0].sign[0]", "form[0].body[0].other[0]", "form[0].end[0].last[0]"] { + let (_, d) = named.iter().find(|(name, _)| name == som).unwrap_or_else(|| panic!("{som} in {named:?}")); + assert_eq!(d.name(b"FT"), Some(b"Sig".as_slice())); + assert_eq!(fields_by_som(doc).get(som).map(String::as_str), Some(som), "the data finds it by its SOM path"); + } + assert_eq!(named.len(), 4, "{named:?}"); + }; + check(&doc); + let body = |doc: &Document| { + let named = full_names(doc); + let parent = |som: &str| named.iter().find(|(name, _)| name == som).and_then(|(_, d)| d.get(b"Parent").and_then(Object::as_ref)); + (parent("form[0].body[0].sign[0]"), parent("form[0].body[0].other[0]")) + }; + let (a, b) = body(&doc); + assert!(a.is_some() && a == b, "the two boxes of one subform share its field"); + // Laid out again: the same fields, not a second set. + let mut doc = doc; + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + check(&doc); + // The XFA taken out: ordinary fields with the same names, without the private key. + strip(&mut doc).unwrap(); + let named = full_names(&doc); + assert!(named.iter().any(|(name, _)| name == "form[0].end[0].last[0]"), "{named:?}"); + let top = pdfcraft_forms_free_fields(&doc); + let node = top.iter().find(|(name, _)| name == "form[0]").map(|(_, d)| d.clone()).expect("the root subform's field"); + assert!(!node.contains(NODE_KEY)); +} + +/// The terminal field named `full`, down the field tree. +fn field_ref(doc: &Document, full: &str) -> Option { + let root = doc.get(doc.root()?); + let acro = doc.resolve(root.as_dict()?.get(b"AcroForm")?); + let mut todo: Vec<(pdfcraft_cos::ObjRef, String)> = + doc.resolve(acro.as_dict()?.get(b"Fields")?).as_array()?.iter().filter_map(Object::as_ref).map(|r| (r, String::new())).collect(); + while let Some((r, prefix)) = todo.pop() { + let d = doc.get(r).as_dict()?.clone(); + let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())).unwrap_or_default(); + let name = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + if name == full { + return Some(r); + } + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + todo.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, name.clone()))); + } + None +} + +/// A box signed on a laid-out form stays signed when the form is laid out again (as after a +/// script adds a row): the new layout's field, named by the same SOM path, holds the same +/// signature and is locked, and the old field is gone from the AcroForm. +#[test] +fn a_signature_on_a_laid_out_box_survives_a_new_layout() { + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let som = "form[0].page1[0].sign[0]"; + let old = field_ref(&doc, som).expect("the box"); + // Signed as signing does it: a signature dictionary as the field's value, and a picture. + let mut sig = Dict::new(); + sig.set(b"Type".to_vec(), Object::name("Sig")); + let sig = doc.add(Object::Dict(sig)); + let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec()))); + doc.update_dict(old, |d| { + d.set(b"V".to_vec(), Object::Ref(sig)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(picture)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + }) + .unwrap(); + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + let new = field_ref(&doc, som).expect("still named by its SOM path"); + assert_ne!(new, old, "a field of the new layout"); + let d = doc.get(new).as_dict().unwrap().clone(); + assert_eq!(d.get(b"V").and_then(Object::as_ref), Some(sig), "the same signature"); + let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref))); + assert_eq!(shown, Some(picture), "and the same picture"); + assert!(matches!(&*doc.get(picture), Object::Stream(_)), "which is still in the file"); + assert!(matches!(&*doc.get(sig), Object::Dict(_)), "as is the signature"); + assert_eq!(d.int(b"Ff").unwrap_or(0) & 1, 1, "a signed box is not signed again"); + let top = pdfcraft_forms_free_fields(&doc); + assert_eq!(top.iter().filter(|(name, _)| name == "form[0]").count(), 1, "the old fields are gone"); +} + +/// A box right in the form's root subform, signed and then laid out again, keeps its signature +/// and picture in the file: its field sits just under the one naming it, and the old fields that +/// give way to the new layout's (freed when this session made them) take nothing it still uses. +#[test] +fn a_signed_box_in_the_root_subform_keeps_its_picture_through_a_new_layout() { + let xdp = r#" +"#; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let som = "form[0].sign[0]"; + let old = field_ref(&doc, som).expect("the box"); + let sig = doc.add(Object::Dict(Dict::new())); + let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec()))); + doc.update_dict(old, |d| { + d.set(b"V".to_vec(), Object::Ref(sig)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(picture)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + }) + .unwrap(); + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + let new = field_ref(&doc, som).expect("still named by its SOM path"); + let d = doc.get(new).as_dict().unwrap().clone(); + let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref))); + assert_eq!(shown, Some(picture)); + assert!(matches!(&*doc.get(picture), Object::Stream(_)), "the picture is still in the file"); + assert!(matches!(&*doc.get(sig), Object::Dict(_)), "and the signature"); +} + +/// A field of the same pass keeps a signature field named by its SOM path from repeating its +/// name, written before the box or after it: with a text field named `form[0]` (as a hostile +/// template may name one), the box keeps its own name and no two fields share one. +#[test] +fn a_signature_field_does_not_repeat_a_name_another_field_has() { + let text = r#""#; + let sign = r#""#; + for (first, second) in [(text, sign), (sign, text)] { + let xdp = format!( + r##" + + + +"## + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let mut top: Vec = pdfcraft_forms_free_fields(&doc).into_iter().map(|(name, _)| name).collect(); + assert!(top.iter().any(|n| n == "sign"), "the box keeps its own name: {top:?}"); + top.sort(); + let len = top.len(); + top.dedup(); + assert_eq!(top.len(), len, "no name twice: {top:?}"); + } +} + +/// A signature box the file already holds a signature for shows that signature, the file's own +/// objects, and is locked; the other box stays unsigned. #[test] fn a_signed_signature_box_shows_the_signature_the_file_holds() { let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap(); @@ -3017,7 +3230,10 @@ fn a_signed_signature_box_shows_the_signature_the_file_holds() { let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); let laid: Vec<_> = fields.iter().map(|f| doc.resolve(f)).filter(|f| f.as_dict().unwrap().contains(SOM_KEY)).collect(); + // The file has a field named `form[0]` already: the unsigned box keeps its own name rather + // than repeat that one. let (signed, unsigned) = (laid[0].as_dict().unwrap(), laid[1].as_dict().unwrap()); + assert_eq!(unsigned.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text()).as_deref(), Some("other")); // The signature dictionary and the picture are the file's own objects, not copies. let value = doc.resolve(signed.get(b"V").unwrap()); assert_eq!(value.as_dict().unwrap().name(b"Type"), Some(&b"Sig"[..])); diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 6131c9c96b..9c3d9ab017 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1126,6 +1126,24 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result Result<(), DataError> { + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Annot")); + p.set(b"Subtype".to_vec(), Object::name("Popup")); + p.set(b"Rect".to_vec(), arr(rect)); + p.set(b"Parent".to_vec(), Object::Ref(parent)); + p.set(b"Open".to_vec(), Object::Bool(open)); + let pr = doc.add(Object::Dict(p)); + doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; + doc.update_dict(page, |pd| { + if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { + a.push(Object::Ref(pr)); + } + })?; + Ok(()) +} + fn import_xfdf(doc: &mut Document, text: &str) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; @@ -1157,19 +1175,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), arr(&rect)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(pop.attribute("open") == Some("yes"))); - let pr = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; - doc.update_dict(page, |pd| { - if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { - a.push(Object::Ref(pr)); - } - })?; + add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; } report.comments += 1; } @@ -1276,7 +1282,11 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec` is an empty field (the export writes one that way), so it + // clears the field like FDF's `/V ()`; a field with kids only names their parent. A + // leaf holding only `` is not read yet, so it leaves the field alone. + let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); + if !vals.is_empty() || !names_kids_or_rich { out.push((full.clone(), vals)); } walk_xfdf(f, &full, out, depth + 1)?; @@ -1445,6 +1455,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); + // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO + // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is + // placed on the page below, as the XFDF import does. + let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); + src.remove(b"Popup"); let mut path: Vec = a.as_ref().into_iter().collect(); let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { @@ -1457,6 +1472,10 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { d.set(b"P".to_vec(), Object::Ref(page)); let r = place(doc, page, d)?; later.remove(&r); + if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { + let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); + add_popup(doc, page, r, &rect, open)?; + } if let Some(fr) = a.as_ref() { placed.insert(fr, r); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 17c48c281b..2bb54f9232 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1933,3 +1933,84 @@ fn annots_of(doc: &Document) -> Vec { .filter(|d| d.get(b"Subtype") != Some(&Object::name("Widget"))) .collect() } + +/// An empty text field exports as a `` with no ``, and the import skipped such a +/// field, so importing the data left the target's old text in place. FDF, XML, CSV and text +/// carry the same empty value and clear the field. +#[test] +fn an_empty_field_in_xfdf_clears_the_field_it_is_imported_into() { + let src = one_field(); + assert_eq!(values(&src), vec![("Name".to_string(), Vec::::new())]); + let exports = [ + ("xfdf", export_xfdf(&src, false, true, "form.pdf").into_bytes()), + ("fdf", export_fdf(&src, false, true, "form.pdf").unwrap()), + ("xml", export_data(&src, Format::Xml).into_bytes()), + ]; + for (format, data) in exports { + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, &data); + assert_eq!(values(&dst), values(&src), "{format}: {report:?}: {}", String::from_utf8_lossy(&data)); + } +} + +/// A leaf holding only `` is not read by the import, so it must leave the field +/// as it was rather than clear it like an empty ``. +#[test] +fn a_richtext_only_field_in_xfdf_leaves_the_field_alone() { + let data = br#" +

Bo

"#; + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, data); + assert_eq!(values(&dst), vec![("Name".to_string(), vec!["Ada".to_string()])], "{report:?}"); +} + +/// An FDF comment's pop-up is an annotation of its own (`/Popup` → a `/Subtype /Popup` object +/// with `/Parent`). The import copied the pop-up's dictionary into the comment as a direct value, +/// which ISO 32000-2 Table 172 says shall be an indirect reference to an annotation on the page. +/// The import now places a pop-up of its own, as the XFDF import does, keeping its place and +/// open state. +#[test] +fn an_fdf_comment_popup_is_not_copied_in_as_a_direct_dictionary() { + let fdf = fdf_with( + "/Annots [2 0 R 3 0 R]", + &[ + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Popup /Rect [40 10 140 60] /Parent 2 0 R /Page 0 /Open true >>".into(), + ], + ); + let mut dst = blank(); + assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1); + let note = find_by_name(&dst, "n").unwrap(); + let popup = dst.get(note).as_dict().unwrap().get(b"Popup").cloned(); + let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let pr = popup.as_ref().and_then(Object::as_ref).filter(|r| page.contains(&Object::Ref(*r))); + let pr = pr.unwrap_or_else(|| panic!("/Popup must be a reference to an annotation on the page: {popup:?}")); + let pd = dst.get(pr).as_dict().cloned().unwrap(); + assert_eq!(pd.name(b"Subtype"), Some(b"Popup".as_slice())); + assert_eq!(pd.get(b"Parent"), Some(&Object::Ref(note))); + assert_eq!(pd.get(b"Open"), Some(&Object::Bool(true))); + let rect: Vec = pd.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(rect, [40.0, 10.0, 140.0, 60.0]); +} + +/// A pop-up without a usable `/Rect` cannot be placed: the comment still imports, with no +/// `/Popup` at all (neither the FDF's dictionary copied in nor a pop-up on the page). +#[test] +fn an_fdf_comment_popup_without_a_rect_is_dropped() { + let fdf = fdf_with( + "/Annots [2 0 R 3 0 R]", + &[ + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Popup /Parent 2 0 R /Page 0 /Open true >>".into(), + ], + ); + let mut dst = blank(); + assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1); + let note = find_by_name(&dst, "n").unwrap(); + assert!(dst.get(note).as_dict().unwrap().get(b"Popup").is_none()); + let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count(); + assert_eq!(popups, 0); +} diff --git a/docs/editing-images.md b/docs/editing-images.md index 67d3613bf1..4ebafd115f 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -38,7 +38,9 @@ the full placement by the same change; resizing keeps the opposite corner fixed, horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not jump. A selected corner keeps control of its drag even over a text label; the drag previews one frame and applies one undoable edit on release. Exclusive crops and their holes move -with the selected image; shared clips stay fixed without moving neighbouring text or artwork. +with the selected image, also when the picture sits inside a clip it shares with its section +(a report drawing each picture as `q re W n cm Do Q` within a table's clip): the picture's own +clip scales with it, and shared clips stay fixed without moving neighbouring text or artwork. `image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e6754d1cee..04434712a3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -79,8 +79,9 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Catalan | `ca` | 2,592 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | | Romanian | `ro` | 2,673 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | | Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,776 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -**19 other languages** ship (33 catalogs plus English = **34** interface languages in Preferences). +**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). The entry counts without percentages were measured on 2026-10-12–14 (Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. @@ -88,6 +89,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Added Persian (`fa`, 2,776 entries, every bundled key); right-to-left like Arabic and Kurdish with the same layout limits; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | diff --git a/docs/localization.md b/docs/localization.md index 90c669892d..234542c755 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one, and Persian locales (`fa`, `fa_IR`, `fa_IR.UTF-8`, `fa-AF`, ...) the Persian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,7 +12,7 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Kurdish, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Romanian, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each), and Czech (2,674 entries) covers the same surface. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). @@ -57,6 +57,8 @@ These need bidirectional support in the text layout itself, not in the catalog. Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). +The Persian catalog (`fa.tsv`, interface name **فارسی**) covers the current 2,776-key union, written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish (`rtl: true`, `bidi::display_rtl` at load; entries are written in logical order here) and has the same four limits above. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. + Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. diff --git a/docs/roadmap.md b/docs/roadmap.md index 02ee7b2125..832fabf54f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -252,6 +252,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | | 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 44c12380ad..8a45bc0691 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -758,8 +758,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"] -notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" +evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] +notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" [[feature]] id = "view.render-knockout" @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -1274,8 +1274,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] -notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." +evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_act_on_the_selected_pages", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_insert_from_a_file_and_extract", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_are_off_without_page_editing_permission"] +notes = "Page tools menu in the panel's header (#4415): insert a blank page or pages from a file after the selection, rotate either way, extract and delete, each on the selected pages (the current one when none is), greyed out where the document's security withholds page changes. Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" @@ -1322,8 +1322,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted"] -notes = "" +evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] +notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." [[feature]] id = "view.layers-panel" @@ -2153,8 +2153,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"] +notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract" @@ -2448,7 +2448,7 @@ status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "<<1>>, <>, <<1 of n>>, <>, dates, Bates" +notes = "<<1>>, <>, <<1 of n>>, <>, dates (US, ISO, day-first with slashes and the dotted <>, <>, <>, <>), Bates. Month names are English only" [[feature]] id = "edit.header-footer-ranges" @@ -3507,8 +3507,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two, and shows page 1 for a named destination" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" [[feature]] id = "organize.custom-properties" @@ -4463,8 +4463,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"] -notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" +evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/view.rs::round_check_boxes_get_a_round_highlight"] +notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (the tint and border round for radio buttons and for check boxes laid out for a round XFA check button); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" [[feature]] id = "form.field-appearance-display" @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -7052,8 +7052,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7065,7 +7065,7 @@ status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" @@ -8484,8 +8484,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id"] -notes = "AccessKit labels on all custom widgets; full audit in M14.3" +evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/it/viewer.rs::the_accessible_window_has_the_window_title_as_its_name"] +notes = "AccessKit labels on all custom widgets; the accessible window is named with the window title and follows it (#4614); full audit in M14.3" [[feature]] id = "a11y.keyboard-only" diff --git a/vendor/README.md b/vendor/README.md index 812d7df10e..e5f57ec1ad 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index a8e7d84c16..531e3b01c9 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -391,6 +391,13 @@ impl ColorSpace { matches!(self.0.as_ref(), ColorSpaceType::DeviceGray) } + // PdfCraft patch (63). + /// Whether this is `/DeviceCMYK` itself, not an ICC-based or default colour space that + /// stands in for it. + pub(crate) fn is_device_cmyk(&self) -> bool { + matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) + } + /// Get the number of components of the color space. pub(crate) fn num_components(&self) -> u8 { match self.0.as_ref() { @@ -1342,6 +1349,18 @@ fn f32_to_u8(val: f32) -> u8 { (val * 255.0 + 0.5) as u8 } +// PdfCraft patch (63). +/// The `/DeviceCMYK` to `/DeviceGray` conversion of ISO 32000-2 §10.4.2.3, +/// `gray = 1.0 - min(1.0, 0.3 c + 0.59 m + 0.11 y + k)`, which §11.5.3 also gives for the +/// luminosity of a soft mask whose group is in `/DeviceCMYK`. A missing component counts as +/// zero, and each component is clamped to `[0, 1]` first (`NaN` and infinities read as zero). +pub(crate) fn device_cmyk_to_gray(cmyk: &[f32]) -> f32 { + let component = |i: usize| clamp_component(cmyk.get(i).copied().unwrap_or(0.0), 0.0, 1.0); + let ink = 0.3 * component(0) + 0.59 * component(1) + 0.11 * component(2) + component(3); + // Every component is in [0, 1], so `ink` is not negative and the result is in [0, 1]. + 1.0 - ink.min(1.0) +} + /// Clamp an input-derived colour component to `[lo, hi]` without panicking on /// NaN or on a reversed range. Non-finite values map to zero. fn clamp_component(value: f32, lo: f32, hi: f32) -> f32 { @@ -1450,3 +1469,36 @@ pub(crate) trait ToRgb { )) } } + +#[cfg(test)] +mod device_cmyk_gray_tests { + use super::device_cmyk_to_gray; + + fn assert_close(cmyk: &[f32], expected: f32) { + let got = device_cmyk_to_gray(cmyk); + assert!( + (got - expected).abs() < 1e-5, + "{cmyk:?}: gray {got}, expected {expected}" + ); + } + + #[test] + fn each_ink_darkens_by_its_iso_weight() { + assert_close(&[0.0, 0.0, 0.0, 0.0], 1.0); + assert_close(&[0.0, 0.0, 0.0, 1.0], 0.0); + assert_close(&[0.0, 0.0, 0.0, 0.5], 0.5); + assert_close(&[1.0, 0.0, 0.0, 0.0], 0.7); + assert_close(&[0.0, 1.0, 0.0, 0.0], 0.41); + assert_close(&[0.0, 0.0, 1.0, 0.0], 0.89); + assert_close(&[0.5, 0.5, 0.5, 0.25], 0.25); + } + + #[test] + fn ink_past_full_coverage_is_black_and_bad_components_read_as_zero() { + assert_close(&[1.0, 1.0, 1.0, 1.0], 0.0); + assert_close(&[0.0, 0.0, 0.0, 1.5], 0.0); + assert_close(&[2.0, -1.0, f32::NAN, f32::INFINITY], 0.7); + assert_close(&[], 1.0); + assert_close(&[0.0, 0.0], 1.0); + } +} diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 1b1655dcd4..ff178b25b9 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -173,6 +173,11 @@ pub struct Context<'a> { /// a limit was reached, the clip count to return to at its `Q`. saved_bytes: usize, skipped_saves: Vec, + // PdfCraft patch (63). + /// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`, + /// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3, + /// §10.4.2.3) rather than through the CMYK profile used for display. + pub(crate) luminosity_in_device_cmyk: bool, } /// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash @@ -251,6 +256,7 @@ impl<'a> Context<'a> { nesting_depth, saved_bytes: 0, skipped_saves: Vec::new(), + luminosity_in_device_cmyk: false, } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index c1fd47f395..a08a60e3bd 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -1,4 +1,4 @@ -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, device_cmyk_to_gray}; use crate::context::{Context, path_as_rect}; use crate::device::Device; use crate::util::{BezPathExt, Float32Ext}; @@ -169,7 +169,13 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> Paint::Color(Color::new(ColorSpace::device_gray(), smallvec![0.0], 0.0)) } } else { - let color = Color::new(data.color_space, data.color, data.alpha); + // PdfCraft patch (63): see `Context::luminosity_in_device_cmyk`. + let color = if context.luminosity_in_device_cmyk && data.color_space.is_device_cmyk() { + let gray = device_cmyk_to_gray(&data.color); + Color::new(ColorSpace::device_gray(), smallvec![gray], data.alpha) + } else { + Color::new(data.color_space, data.color, data.alpha) + }; if let Some(tf) = &data.transfer_function { Paint::Color(Color::from_rgba(tf.apply(&color.to_rgba()))) diff --git a/vendor/hayro-interpret/src/soft_mask.rs b/vendor/hayro-interpret/src/soft_mask.rs index 4d97d565b5..1cd60a17e9 100644 --- a/vendor/hayro-interpret/src/soft_mask.rs +++ b/vendor/hayro-interpret/src/soft_mask.rs @@ -1,4 +1,4 @@ -use crate::color::{Color, ColorComponents, ColorSpace}; +use crate::color::{Color, ColorComponents, ColorSpace, DeviceFamily, device_cmyk_to_gray}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::function::Function; @@ -59,6 +59,9 @@ struct Repr<'a> { transfer_function: Option, settings: InterpreterSettings, background: Color, + // PdfCraft patch (63). + /// A luminosity mask whose group colour space is `/DeviceCMYK`. + luminosity_in_device_cmyk: bool, xref: &'a XRef, nesting_depth: u32, } @@ -107,10 +110,17 @@ impl<'a> SoftMask<'a> { // PdfCraft patch: the group's /CS is optional (ISO 32000-2 §11.6.5.2; Chrome/Skia omit // it). Upstream returned None here, silently dropping the whole soft mask. Fall back to // DeviceRGB, the blending space our renderer composites in. - let cs = group + let cs_object = group .dict .get::>(GROUP) - .and_then(|g| g.get::>(CS)) + .and_then(|g| g.get::>(CS)); + // PdfCraft patch (63): the group is composited in `/DeviceCMYK` itself. An ICC-based + // or calibrated group space keeps its own conversion. + let group_in_device_cmyk = cs_object + .as_ref() + .and_then(DeviceFamily::of_object) + .is_some_and(|family| family == DeviceFamily::Cmyk); + let cs = cs_object .and_then(|o| ColorSpace::new(o, &context.interpreter_cache.object_cache)) .unwrap_or_else(ColorSpace::device_rgb); let transfer_function = dict @@ -121,7 +131,19 @@ impl<'a> SoftMask<'a> { LUMINOSITY => { let color = dict .get::(BC) - .map(|c| Color::new(cs, c, 1.0)) + .map(|c| { + // PdfCraft patch (63): a DeviceCMYK backdrop's luminosity is its gray + // (ISO 32000-2 §11.5.3), as for the group's content. + if group_in_device_cmyk && c.len() == 4 { + Color::new( + ColorSpace::device_gray(), + smallvec![device_cmyk_to_gray(&c)], + 1.0, + ) + } else { + Color::new(cs, c, 1.0) + } + }) .unwrap_or(Color::new(ColorSpace::device_gray(), smallvec![0.0], 1.0)); (MaskType::Luminosity, color) @@ -157,6 +179,7 @@ impl<'a> SoftMask<'a> { settings: context.settings.clone(), xref: context.xref, background, + luminosity_in_device_cmyk: group_in_device_cmyk && mask_type == MaskType::Luminosity, parent_resources, nesting_depth, }))) @@ -174,6 +197,7 @@ impl<'a> SoftMask<'a> { state, self.0.nesting_depth, ); + ctx.luminosity_in_device_cmyk = self.0.luminosity_in_device_cmyk; draw_form_xobject(&self.0.parent_resources, &self.0.group, &mut ctx, device); }