diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be9e2c448e..87b11279e7 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" +sha256 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f" +sha256 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" +sha256 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517" +sha256 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543" +sha256 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1" +sha256 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12" +sha256 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb" +sha256 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" +sha256 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b" +sha256 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" +sha256 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452" +sha256 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587" +sha256 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3" +sha256 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c" +sha256 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa" +sha256 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac" +sha256 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679" +sha256 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf" +sha256 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa" +sha256 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6" +sha256 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0" +sha256 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..4e661341dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" @@ -4100,6 +4105,7 @@ name = "pdfcraft-engine" version = "0.8.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", diff --git a/Cargo.toml b/Cargo.toml index 597dd316fa..27fd003e89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +# 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.8.0" } pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } diff --git a/ROADMAP.md b/ROADMAP.md index 1f67a4efec..c296eb1380 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -158,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. @@ -499,6 +501,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..4d9857ce19 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -231,7 +231,10 @@ fn main() -> eframe::Result { // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own // window. let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; - let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { + // Absolute for this instance too: links relative to a document resolve against its folder, + // never the working directory. + let files = single_instance::absolute(&files); + let instance = match single_instance::claim(&files, may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7de0287634..74567eb488 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } pdfcraft-measure = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } pdfcraft-render = { workspace = true } diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 5dd9aff5b5..4902d72f53 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError { Empty, TooLong, Hidden, - UrlOrNetwork, + /// A URL, a network or device path, or a path that climbs above its root. + NotLocal(craft_docpath::Refused), MissingSourcePath, /// The target doesn't end in `.pdf`. NotPdf, @@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError { Self::Empty => f.write_str("the file specification is empty"), Self::TooLong => f.write_str("the file specification is too long"), Self::Hidden => f.write_str("the file specification contains hidden or control characters"), - Self::UrlOrNetwork => f.write_str("only local document paths are supported"), + Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"), Self::MissingSourcePath => f.write_str("a relative link needs a source document path"), Self::NotPdf => f.write_str("the linked file is not a .pdf file"), Self::NotAFile => f.write_str("the linked path is not a regular file"), @@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { } /// Resolve a PDF remote-Go-To file specification to a local path without opening it. -/// Relative paths are based on the source document; an in-memory source has no fallback to CWD. +/// Relative paths are based on the source document, which should be an absolute path; an +/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are +/// refused ([`craft_docpath::resolve`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< if raw.chars().any(hides_text) { return Err(RemoteDocumentPathError::Hidden); } - if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") { - return Err(RemoteDocumentPathError::UrlOrNetwork); - } - let path = Path::new(raw); - let drive_absolute = remote_document_path_is_absolute(raw); + // A GoToR file specification is a path, not a URL: any scheme of two or more characters is + // refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included. if let Some((scheme, _)) = raw.split_once(':') { - let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + let is_scheme = scheme.len() > 1 + && scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); - let windows_drive = scheme.len() == 1 && drive_absolute; - if valid_scheme && !windows_drive { - return Err(RemoteDocumentPathError::UrlOrNetwork); + if is_scheme { + return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - if drive_absolute { - return Ok(path.to_path_buf()); + let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; + // A relative result would be read against the working directory. + if !path.is_absolute() { + return Err(RemoteDocumentPathError::MissingSourcePath); } - let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?; - let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?; - Ok(parent.join(path)) + Ok(path) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -503,23 +503,56 @@ mod tests { use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; use std::path::Path; - let source = Path::new("/docs/chapter/source.pdf"); - assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf"))); + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + let folder = source.parent().unwrap(); + assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf"))); + assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path())); + #[cfg(unix)] assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); + assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); assert_eq!(resolve(Some(source), ""), Err(E::Empty)); assert_eq!(resolve(Some(source), " "), Err(E::Empty)); - for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}"); - } - for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"] + for url in + ["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}"); + assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); } assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); } + #[test] + fn remote_document_paths_to_other_computers_and_devices_are_refused() { + use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; + + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + // Refused before any filesystem access: none of these exist, and a stat of a network path + // would contact its host. + for not_local in [ + "//server/share/a.pdf", + "\\\\server\\share\\a.pdf", + "\\\\h\\s\\a.pdf", + "\\\\?\\UNC\\h\\s\\a.pdf", + "\\??\\UNC\\h\\s\\a.pdf", + "/??/x", + "\\??\\C:\\a.pdf", + "\\\\.\\device\\a.pdf", + "/\\server/share/a.pdf", + "NUL.pdf", + ] { + assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}"); + } + // Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share. + #[cfg(unix)] + { + let doubled = format!("/{}", source.with_file_name("a.pdf").display()); + assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}"); + } + } + #[test] fn urls_follow_the_artcraft_scheme() { assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629c..0de1c6a7ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -738,8 +738,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { match action { Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), Some(crate::sign_ui::CertAction::Export(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 23754e13ef..f5e9e0319b 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String { if reserved { format!("_{stem}") } else { stem.to_string() } } -/// A safe native Save suggestion for a PDF-supplied attachment name. -/// This changes only the suggestion, never the stored name or the selected destination. -#[cfg(any(test, not(target_arch = "wasm32")))] +/// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name +/// (which later names files written from that tab). It never changes the stored name or a +/// destination the user selected. pub(crate) fn attachment_file_name(name: &str) -> String { let cleaned: String = name .chars() @@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String { if reserved { format!("_{name}") } else { name.to_string() } } +/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or +/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data +/// streams. +pub(crate) fn is_plain_file_name(name: &str) -> bool { + let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic()); + !(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':'))) +} + /// Pages ▸ Rotate Pages. #[derive(Clone, Debug, PartialEq)] pub struct RotateDraft { @@ -667,7 +675,12 @@ impl PdfCraftApp { /// [`Self::write_files`], then `after` once every file is written: now, or on a later frame /// when the user chooses the folder. `after` never runs when writing fails or is cancelled. + /// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written. pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) { + if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) { + self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]); + return; + } #[cfg(not(target_arch = "wasm32"))] { let write = move |app: &mut Self, dir: std::path::PathBuf| { @@ -996,7 +1009,7 @@ impl PdfCraftApp { #[cfg(test)] mod stem_tests { - use super::{attachment_file_name, file_stem_from_user}; + use super::{attachment_file_name, file_stem_from_user, is_plain_file_name}; #[test] fn user_file_names_stay_inside_the_folder_and_writable_everywhere() { @@ -1049,4 +1062,36 @@ mod stem_tests { assert_eq!(attachment_file_name(&once), once); } } + + #[test] + fn write_files_refuses_names_that_leave_the_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let out = dir.join("a").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let absolute = dir.join("abs.pdf").to_string_lossy().into_owned(); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] { + assert!(!is_plain_file_name(bad), "{bad}"); + // A plain name first: nothing is written when any name in the batch is refused. + app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}"); + } + let names: Vec<_> = walk(&dir); + assert!(names.is_empty(), "{names:?}"); + app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t"); + assert_eq!(walk(&dir), vec![out.join("x.pdf")]); + let _ = std::fs::remove_dir_all(&dir); + } + + fn walk(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) } + } + out + } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 5a0e6e9c96..246a26fa6c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1703,6 +1703,7 @@ 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 لا صفحات تطابق هذه الاختيارات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 90791f4a86..a39489aedc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1664,6 +1664,7 @@ 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} Записан е 1 файл в {dir} Wrote {n} files to {dir} Записани са {n} файла в {dir} No pages match those choices Няма страници, отговарящи на този избор diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4bbb9a5e77..3b7c677e00 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1755,6 +1755,7 @@ key Space Mezerník Couldn't use {name}: {e} {name} nelze použít: {e} Couldn't extract pages: {e} Stránky nelze extrahovat: {e} Couldn't write {name}: {e} {name} nelze zapsat: {e} + the name is not a plain file name název není prostý název souboru Wrote 1 file to {dir} Zapsán 1 soubor do {dir} Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir}) No pages match those choices Těmto volbám neodpovídají žádné stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7965435257..c98c405616 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1637,6 +1637,7 @@ Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e} Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e} Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e} + the name is not a plain file name der Name ist kein einfacher Dateiname Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben No pages match those choices Keine Seiten entsprechen dieser Auswahl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 57de6eef07..9be3e19c2e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1215,6 +1215,7 @@ 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} Γράφτηκε 1 αρχείο στον φάκελο {dir} Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir} No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4eb634468..6a48611a37 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1714,6 +1714,7 @@ action wizard Start Iniciar Couldn't use {name}: {e} No se pudo usar {name}: {e} Couldn't extract pages: {e} No se pudieron extraer las páginas: {e} Couldn't write {name}: {e} No se pudo escribir {name}: {e} + the name is not a plain file name el nombre no es un nombre de archivo simple Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir} Wrote {n} files to {dir} Se han escrito {n} archivos en {dir} No pages match those choices Ninguna página coincide con esas opciones diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62acea1da8..0de88637a7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1632,6 +1632,7 @@ Couldn't use {name}: {e} Impossible d'utiliser {name} : {e} Couldn't extract pages: {e} Impossible d'extraire les pages : {e} Couldn't write {name}: {e} Impossible d'écrire {name} : {e} + the name is not a plain file name le nom n'est pas un simple nom de fichier Wrote 1 file to {dir} 1 fichier écrit dans {dir} Wrote {n} files to {dir} {n} fichiers écrits dans {dir} No pages match those choices Aucune page ne correspond à ces choix diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2238e1ade5..2d7dd82a1b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1622,6 +1622,7 @@ Couldn't use {name}: {e} {name} használata nem sikerült: {e} Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e} Couldn't write {name}: {e} {name} írása nem sikerült: {e} + the name is not a plain file name a név nem egyszerű fájlnév Wrote 1 file to {dir} 1 fájl ide írva: {dir} Wrote {n} files to {dir} {n} fájl ide írva: {dir} No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3fa2263a0b..1695333744 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1709,6 +1709,7 @@ Couldn't use {name}: {e} Impossibile usare {name}: {e} Couldn't extract pages: {e} Impossibile estrarre le pagine: {e} Couldn't write {name}: {e} Impossibile scrivere {name}: {e} + the name is not a plain file name il nome non è un semplice nome file Wrote 1 file to {dir} Scritto 1 file in {dir} Wrote {n} files to {dir} Scritti {n} file in {dir} No pages match those choices Nessuna pagina corrisponde a queste scelte diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ee9439e311..c863031743 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1713,6 +1713,7 @@ 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} に 1 ファイルを書き込みました Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました No pages match those choices 条件に一致するページはありません diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 827165ae3a..64a4ea26f4 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1634,6 +1634,7 @@ 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} 1 файл {dir} ішіне жазылды Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 56a785cd3e..d98befcf47 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1965,6 +1965,7 @@ comment menu Edit Redaguoti Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e} Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e} Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e} + the name is not a plain file name vardas nėra paprastas failo vardas Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e} Create link Kurti saitą Created 1 link Sukurtas 1 saitas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3dbb247056..e8df527941 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1696,6 +1696,7 @@ Couldn't use {name}: {e} Nie można użyć {name}: {e} Couldn't extract pages: {e} Nie można wyodrębnić stron: {e} Couldn't write {name}: {e} Nie można zapisać {name}: {e} + the name is not a plain file name nazwa nie jest zwykłą nazwą pliku Wrote 1 file to {dir} Zapisano 1 plik w {dir} Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n} No pages match those choices Żadna strona nie pasuje do wybranych opcji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8b3baa2d39..dbefb9d04a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1538,6 +1538,7 @@ Couldn't use {name}: {e} Não foi possível usar {name}: {e} Couldn't extract pages: {e} Não foi possível extrair páginas: {e} Couldn't write {name}: {e} Não foi possível gravar {name}: {e} + the name is not a plain file name o nome não é um nome de arquivo simples Wrote 1 file to {dir} 1 arquivo gravado em {dir} Wrote {n} files to {dir} {n} arquivos gravados em {dir} No pages match those choices Nenhuma página corresponde a essas opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d0f8fc239c..d5cd9d6cba 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1704,6 +1704,7 @@ 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} Записан 1 файл в {dir} Wrote {n} files to {dir} Записано {n} файлов в {dir} No pages match those choices Страницы не соответствуют этим выбору diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 92d9c9a6e3..560450c256 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -479,6 +479,7 @@ Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e} Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e} Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e} + the name is not a plain file name ime ni preprosto ime datoteke Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e} Counterclockwise 90 degrees 90 stopinj v levo Country/Region Država/regija diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 07b716eac1..c3900c825d 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1623,6 +1623,7 @@ action wizard Start ప్రారంభించు 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}లో 1 ఫైల్ సేవ్ చేయబడింది Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 30e19f2132..bec543b02e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1603,6 +1603,7 @@ Couldn't use {name}: {e} {name} kullanılamadı: {e} Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e} Couldn't write {name}: {e} {name} yazılamadı: {e} + the name is not a plain file name ad, basit bir dosya adı değil Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı No pages match those choices Bu seçimlerle eşleşen sayfa yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c60799ef1f..379e47317b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1701,6 +1701,7 @@ 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} Записано 1 файл у {dir} Wrote {n} files to {dir} Записано файлів ({n}) у {dir} No pages match those choices Жодна сторінка не відповідає вибраним параметрам diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b072755236..b68d6f61e2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1955,6 +1955,7 @@ comment menu Edit Sửa Couldn't use the photo: {e} Không thể dùng ảnh: {e} Couldn't use {name}: {e} Không thể dùng {name}: {e} Couldn't write {name}: {e} Không thể ghi {name}: {e} + the name is not a plain file name tên không phải là tên tệp đơn thuần Couldn't write {path}: {e} Không thể ghi {path}: {e} Create link Tạo liên kết Created 1 link Đã tạo 1 liên kết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2297d1269a..1f37b431d8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1671,6 +1671,7 @@ 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} 已将 1 个文件写入{dir} Wrote {n} files to {dir} 已将{n}个文件写入{dir} No pages match those choices 所选条件没有匹配的页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2ddb33dd6d..0cd22d7ff5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1615,6 +1615,7 @@ 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} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} No pages match those choices 所選條件沒有匹配的頁面 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c6..e7df5d6706 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1520,7 +1520,8 @@ impl PdfCraftApp { match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Ok(bytes), true) => { - if let Err(e) = self.open_bytes(&att.name, None, bytes) { + // The tab name later names files written from the tab (Split, Extract pages). + if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d691488076..2007dc0afa 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -784,8 +784,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.start_signing(page, None, Some(field), None); } Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => { - let pem = crate::sign_ui::certificate_pem(&c); - app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate"); + app.export_certificate(&c); } None => {} } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 988833e30c..3cb67bb8ee 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry { } impl PdfCraftApp { + /// Export certificate: write `c` as PEM into a chosen folder, named after its subject. + pub(crate) fn export_certificate(&mut self, c: &Certificate) { + let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name())); + self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate"); + } + /// Start signing: the rectangle (or field) is known; show Sign with a Digital ID. pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option, certify: Option) { self.refresh_os_key_store_ids(); @@ -1496,6 +1502,23 @@ mod tests { Ok(PathBuf::from(&app.digital_ids[i].path)) } + #[test] + fn an_exported_certificate_is_written_inside_the_chosen_folder() { + let dir = scratch(); + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let key = PrivateKey::generate_p256().unwrap(); + let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap(); + let mut app = PdfCraftApp::new(); + app.export_dir_override = Some(out.to_string_lossy().into_owned()); + app.export_certificate(&cert); + let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(written, vec![std::ffi::OsString::from("out")]); + let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap(); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn signature_status_style_gives_each_verdict_its_panel_icon() { assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check"); diff --git a/crates/ui-egui/tests/pickers.rs b/crates/ui-egui/tests/pickers.rs index e7db8cffa6..bf9df10b39 100644 --- a/crates/ui-egui/tests/pickers.rs +++ b/crates/ui-egui/tests/pickers.rs @@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() { /// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion. fn attachment_fixture() -> Vec { - let name = "invoice\u{202e}fdp.exe"; + attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes") +} + +/// A one-page PDF with one embedded file whose /UF name is `name`. +fn attachment_pdf(name: &str, data: &[u8]) -> Vec { let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::(); - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_string(), - "<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(), - format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"), + let objs: [Vec; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 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", data.len()).as_bytes(), data, b"\nendstream"].concat(), + format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").into_bytes(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + 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(b"xref\n0 6\n0000000000 65535 f \n"); @@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() { assert_eq!(doc.info.attachments[index].name, raw_name); assert_eq!(doc.bytes.as_ref(), &original); } + +/// Every file under `dir`, recursively, sorted. +fn files_under(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) } + } + out.sort(); + out +} + +#[test] +fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() { + let dir = AttachmentDir::new(); + let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned(); + for name in ["../../x.pdf", absolute.as_str()] { + let out = dir.0.join("a").join("b").join("out"); + std::fs::create_dir_all(&out).unwrap(); + let mut h = harness(&[]); + h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().attachment_action(id, 0, true); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab"); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1))); + let written = files_under(&dir.0); + assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}"); + assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}"); + // Extract writes page 1 under the name Split gave it. + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.state_mut().extract_selection(); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast); + assert_eq!(files_under(&dir.0), written, "{name}"); + std::fs::remove_dir_all(dir.0.join("a")).unwrap(); + } +} diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 2b179e2007..fdc9aa401c 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,6 +18,9 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). +Plain AcroForm XML imports retain all direct text around comments and processing instructions, +including CDATA and character references. Nested-element and XFA interpretation are unchanged. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 50326c4e71..6706b11eed 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1114,7 +1114,8 @@ fn import_table(doc: &mut Document, text: &str) -> Result { .map(|c| { let name = c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string()); - (name, vec![c.text().unwrap_or("").to_string()]) + // Comments and processing instructions do not end a field's character data. + (name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()]) }) .collect() } else if t.lines().next().is_some_and(|l| l.contains('\t')) { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index d244df1adf..f5cc52cec7 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1011,3 +1011,34 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() { assert_eq!(values(&reopened)[0].1, want, "{xml_text}"); } } + +#[test] +fn xml_form_import_preserves_text_around_comments_and_instructions() { + for (body, expected) in [ + ("leftright", "leftright"), + ("leftmiddleright", "leftmiddleright"), + ("]]>&right", "&right"), + (" left right ", " left right "), + ("", ""), + ] { + let mut original = one_field(); + let root = original.root().unwrap(); + original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap(); + set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap(); + let source = write_full(&original, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(source.clone())).unwrap(); + let xml = format!("{body}"); + let report = import(&mut doc, xml.as_bytes()).unwrap(); + assert_eq!(report.fields, 1); + assert!(report.rejected.is_empty()); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap(); + let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] }; + assert_eq!(name.value, want, "XML body: {body}"); + let root = reopened.get(reopened.root().unwrap()); + assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained"); + } + assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]); + } +} diff --git a/deny.toml b/deny.toml index 2362d56822..ed9c26c206 100644 --- a/deny.toml +++ b/deny.toml @@ -48,6 +48,13 @@ ignore = [ # winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows # and is not in the binary. { id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" }, + # zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed + # release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` -> + # `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform. + # Revisit when the accesskit/zbus chain moves to a maintained successor. + { id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" }, + { id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" }, ] # egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md diff --git a/docs/roadmap.md b/docs/roadmap.md index 65dd7121bb..1de0386353 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -118,7 +119,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -180,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b836069b63..42ad28d1c8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4704,8 +4704,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited" +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions"] +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format." [[feature]] id = "form.merge-to-spreadsheet"