M4: rename colliding form field names when combining (organize.field-name-conflicts) (#1155)

* M4: rename colliding form field names when pages are imported or combined

Insert Pages from File, Combine Files and the grouped combine registered
incoming form fields under names the destination already used, silently
blurring them into one field (fields of one fully-qualified name are one
field, so their values mix). Importing now walks both sides' fully
qualified names (parent.T.child) — including names that hang only off
page widgets, which /AcroForm never lists — and renames a colliding
field's own /T to the first free variant (Text1-1, Text1-2, …), so its
children keep their place in the tree and the second of two colliding
incoming fields is renamed too.

A field_conflicts policy (rename by default; merge keeps the older
behaviour) and reporting — ImportOutcome::renamed_fields, the combine
outcomes, and a field_name_conflicts(dst, src) pre-scan — let callers
choose and prepare; engine, tool and Combine-tab wiring follow.

* M4: engine and tool wiring for renamed field names

Insert Pages from File carries a field_conflicts policy (rename by
default) and reports what it renamed: a repair-log note names each
old → new pair and warns that scripts referring to the old names were
not rewritten, and the undo step's label counts the renames, so nothing
is slipped in silently. Undo restores the original names, as ever.

The session's combine paths gain reporting variants —
combine_unlocked_reporting, combine_grouped_reporting — returning the
bytes and the renames, and doc_combine takes field_conflicts
("rename" | "merge") and lists renamed_fields [[old, new], …] in its
result when fields were renamed, so agents can find what their scripts
must call the field now.

* M4: the Combine tab warns before combining that field names collide

The Warnings column previews, per file, the form fields whose names
collide with earlier files' and will be renamed (Text1 → Text1-1, …),
computed once per list by a new in-order pre-scan (organize
combine_field_conflicts over the whole list, engine wrapper over the
tab's byte sources; a file that cannot be opened previews as nothing,
as its row already says why). The message is plural-aware and
translated in all sixteen catalogs.

* M4: parity for renamed field names on combine and insert

organize.field-name-conflicts ships: rename by fully-qualified name
(or merge, on request), reported everywhere it happens — repair log
and undo label on insert, doc_combine's result, the Combine tab's
warning column. The notes say what is still missing: the tab's preview
ignores narrowed page ranges, the UI offers no merge choice,
Create ▸ Multiple files renames silently, and scripts and XFA bindings
that reference old names are never rewritten.

* M4: review fixes for the field-name conflict work

Four findings from the PR's code review:

- Clippy: the -D warnings gate failed on the new engine tests (a
  format! with no interpolation, two drop(doc) no-ops on a
  reference); the branch could not pass its own CI.

- The Combine tab's preview warned that a file split into parts
  collides with itself: the preview made one source per row, while
  the combine groups parts of one file and imports it once. The
  preview now dedupes by group (each group's first entry stands for
  the file, as the combine does), and every part shows the file's
  preview.

- Collision detection compared /T bytes, so one name written in two
  encodings (PDFDocEncoding, UTF-16BE) did not collide — the exact
  silent blur this work exists to prevent — and renaming a UTF-16BE
  name rewrote it as from_utf8_lossy mojibake. Names are now compared,
  suffixed and written as decoded text (PdfString::to_text /
  PdfString::text), which is also how the forms crate reads them.

- The engine's combine_unlocked/combine_grouped duplicated their
  _reporting twins line for line; they now delegate with the Rename
  policy, as organize's own wrappers already did.

* Field-name conflicts: only Combine and Insert from File rename

import_pages (Duplicate, Replace) and extract_pages (Extract, Split) go back to Merge, so
copies of a document's own fields keep their names and a source with two same-named field
roots isn't split apart. Pasting pages into the document they were copied from also merges;
pasting into another document renames as Insert from File does. Regression tests for
duplicate, replace, extract/split (organize), duplicate (engine) and paste (ui-egui); the
PR's rename tests call import_pages_with(Rename) explicitly.

Merge fix-up: main made edit labels a translatable Label, so the rename suffix is now two
wordings in EDIT_LABELS ("Insert pages from {name} — 1 field renamed" / "— fields
renamed"), translated in all 20 catalogs. The Combine warning's @plural row is added to
el, kk, sl and tr. Catalog hashes refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eduardo Soares 2026-10-11 22:18:21 -03:00 committed by GitHub
parent 09287e547f
commit 012b34a2a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1208 additions and 69 deletions

View File

@ -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 = "75ff8eaf26b014f36d77930ded888c5b66602f9af54d41ef3736f7dacadbd3b0"
sha256 = "c11d1ab23626a150dcc2c86ccc8da178b14b05005362691c9534c024bd33be33"
# 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 = "78c01cd3d757a67641f430bb859b5aec0a9c09f69f93c03dc17e3123f2435088"
sha256 = "1fc90de4950f1ec603a92490739e7774ce460b2519c2143847c462b73a1a3192"
[[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 = "79a895e833f9defe51286cbebf9116f872d1a97c35bb1f96e06e486ceb5daca6"
sha256 = "ec59f43208ce08488a9918a5fe78debf1f07e0f50d231a1c875f013c8f706166"
[[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 = "b11b95a369d61d931ee57a80a29d6cc0f063515eba48446aff0049b735428da7"
sha256 = "0a83d58709ea31d97accf2be4ac2e588b566a6aa8e9b3ea29b7d45b5c64bb020"
[[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 = "6f00dbbe9e761a8d8d3ea4b686cbd922a52934f3b076396a09b020d0bcdacd99"
sha256 = "67249c2032d2420e90e84c16194a7f893b57222b600a8ca448bb5dd8425553b7"
[[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 = "107b6252dfb7f6a496676d22e7e6d92f15a534f7e8074b30463d9db9195df81b"
sha256 = "681e0f1ae2e612173b2a1bc3ead5f5eb61f5ac006a961c703e427c258f6bbde1"
[[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 = "22c54fa66b10cd104c042ca3190711dc1fdf021c58ff9158cf3e15d33f6ad5ed"
sha256 = "11f3b405f5dd21e25d45ee1543c306bff58104f37ed7ef26a4e13f453ce25798"
[[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 = "2b309b341c1253691328e4996f8fa837c6ddc10c4ea4f2404ae56c23a84ac995"
sha256 = "29582e00b1dac3a5589d23d4aab7a7ebf53ae71ea33cf2c94665ffd89d0b752e"
[[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 = "13a7ed2b84a70a31a460457547aa8e7775861321352811dab6bb00293bfe8a64"
sha256 = "d67f16d97586b0c3f6026150ef9590ba0f93d039159d93bcbc6185bb13e1021b"
[[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 = "0586dc5ed21e7414eabe9b4d3b2b2cfa3933bd299040a5b09ed4b82691096b00"
sha256 = "279aaa24b7a6229da6b1d97b4f9d3cb213862996e32f8230ccba982252aeca64"
[[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 = "3f8b27806f7bb47198f94db53c54a001215e1290cb58745772fe9daeb680c3ac"
sha256 = "1bda6860cd2bd330369746edc97737ebbfe7dc859cde478cc1e9276e852323a9"
[[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 = "a6892bb1d5693c87a5a551e1bda3bbeeca7cc8e436f88b49b66202a72ba9ff73"
sha256 = "55671fb2a1717bac56789c8ab31f8412efb119a61c6d502eb0319ead7f5e9f98"
[[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 = "47af07bc3ba18941ca5dab2aef6d0c3249e497af00db47daf5dafcc6dd96b8fb"
sha256 = "408cf4819e263d141c00ea120b2b49e4c4f144226f66f10ac5b8500e963547d1"
[[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 = "6c008e3641f080d1f457553935744641014d4ee6c89e65d2fe0b55a4d7dfa7bd"
sha256 = "8024a9e9b14a44d2b75d0a5d1aec21a3bea6662c204769d2dc712c3da484202c"
[[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 = "27765e0db6c2f5be8b900afc1e957225478a524db16007051976128bf8452d8a"
sha256 = "44e5490cc1a04190c24be19e3f8f84f8fa2a540fa411b757435109e197e20dfd"
[[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 = "4a9c054ea4a6c1a2611e4057259901b871e09a7fa24f0dedc01381135d587193"
sha256 = "5f6f003fda06389782371c65fb24290b9c88cede3b67fa6f4700a3c5996eac76"
[[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 = "014b35326ce9082426512f959bcf2ab8e0f830dced65ce33edb1edb95f99cafe"
sha256 = "17ca14b6eed104f60e0e8eb2b4cf5e0fb26d73c602707c87db4ce755eaba309c"
[[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 = "08de1da009efedcf173421eec4a60006f6a4a5c6f8e8927c3bea9b9f0595de6a"
sha256 = "7efbefe9cbaa0f0a92f94c34d6435f214556068612c61cfafe79e80c85302356"
[[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 = "fe53290bae4167b899ef16bebe3df264c51a6f15a20cab6b7ea1307b658e4040"
sha256 = "e24109d16048d1867d61509e12ebd9105c1d1530bdca5746844707d7f5e29348"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -3339,4 +3339,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2a55048528aed00fa7501842c2c26f4ec02a3c1e163c365cfa28b01b99288f9d"
sha256 = "85dec9ece83ff99732854d00a5361133ec6ff98afad8d2563d3e2eabd970f042"

View File

@ -1361,7 +1361,7 @@ impl Automation {
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at })
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
}
/// Write freshly created bytes to `out` and/or open them as a new document.
@ -1498,6 +1498,13 @@ impl Automation {
}
Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())),
};
let field_conflicts = match a.opt_str("field_conflicts")? {
None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename,
Some("merge") => pdfcraft_organize::FieldConflicts::Merge,
Some(other) => {
return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}")));
}
};
let mut sources: Vec<CombineSource> = Vec::new();
let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new();
for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() {
@ -1525,8 +1532,8 @@ impl Automation {
sources.push((name, bytes, range));
}
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let bytes = match groups {
None => self.session.combine_unlocked(&sources, &passwords),
let combined = match groups {
None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts),
Some(groups) => {
// A path without a group is a file of its own: give it a key no group uses.
let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1));
@ -1541,11 +1548,15 @@ impl Automation {
}
});
}
self.session.combine_grouped(&sources, &keys, &passwords)
self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts)
}
}
.map_err(failed)?;
self.deliver(a, "Combined", bytes)
let mut result = self.deliver(a, "Combined", combined.bytes)?;
if !combined.renamed_fields.is_empty() {
result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());
}
Ok(result)
}
fn doc_split(&mut self, a: &Args) -> Result<Value> {

View File

@ -221,7 +221,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password.")
t("doc_combine", "Combine files", "Combine files, in order, into one PDF (bookmarks are kept under one entry per file). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back. groups, also in step with paths, splits a file around others: entries with the same number (they must name the same path) are one file, copied once, so it keeps one bookmark at its first page, its links and its form fields; null for a file of its own. A group uses its first entry's password. When two files' forms name the same field, field_conflicts says what happens: \"rename\" (the default) keeps the fields separate by renaming the later one (Text1 becomes Text1-1) and the result lists renamed_fields as [[old, new], …] — scripts that refer to the old name are not rewritten; \"merge\" makes them one field, as before.")
.cmd("page.combine")
.with(schema(
json!({
@ -229,6 +229,7 @@ pub fn tools() -> Vec<ToolDef> {
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"groups": { "type": "array", "items": { "type": ["integer", "null"], "minimum": 0 } },
"field_conflicts": { "type": "string", "enum": ["rename", "merge"] },
"out": save_out,
"open": open,
}),

View File

@ -49,6 +49,29 @@ fn auto(dir: &Path) -> Automation {
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
}
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
@ -240,6 +263,23 @@ fn edit_undo_redo_and_save_round_trip() {
assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2);
}
/// organize.field-name-conflicts: combining files whose forms share a field name keeps the
/// fields separate by renaming, says so in the result, and can be told to merge instead; any
/// other policy is an argument error.
#[test]
fn combine_renames_colliding_field_names() {
let dir = workdir("combine-field-conflicts");
std::fs::write(dir.join("form-a.pdf"), form_pdf("Text1")).unwrap();
std::fs::write(dir.join("form-b.pdf"), form_pdf("Text1")).unwrap();
let mut a = auto(&dir);
let combined = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "open": true }));
assert_eq!(combined["renamed_fields"], json!([["Text1", "Text1-1"]]), "{combined}");
let merged = ok(&mut a, "doc_combine", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "merge" }));
assert!(merged.get("renamed_fields").is_none(), "merge renames nothing: {merged}");
let bad = a.call("doc_combine", &json!({ "paths": ["form-a.pdf", "form-b.pdf"], "field_conflicts": "bogus" }));
assert!(matches!(bad, Err(ToolError::InvalidArgs(_))));
}
/// #569: images and text files combine with PDFs, each made into a PDF first.
#[test]
fn combine_takes_images_and_text_files() {

View File

@ -36,7 +36,7 @@ pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use text_preview::TextEditPreview;
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
pub use pdfcraft_organize::LabelStyle;
@ -750,6 +750,14 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes:
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
/// separate (old fully-qualified name → new one), in order across the sources.
#[derive(Debug)]
pub struct Combined {
pub bytes: Arc<Vec<u8>>,
pub renamed_fields: Vec<(String, String)>,
}
/// A bookmark as the bookmark list shows it.
#[derive(Clone, Debug, PartialEq)]
pub struct BookmarkRow {
@ -1001,6 +1009,8 @@ pub enum Edit {
bytes: Arc<Vec<u8>>,
pages: Option<Vec<usize>>,
at: usize,
/// How a form field whose name the document already uses is kept separate.
field_conflicts: pdfcraft_organize::FieldConflicts,
},
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
AddBookmark {
@ -1479,6 +1489,8 @@ pub const EDIT_LABELS: &[&str] = &[
"Change Keywords",
"Change {key}",
"Insert pages from {name}",
"Insert pages from {name} — 1 field renamed",
"Insert pages from {name} — fields renamed",
"Add bookmark",
"Rename bookmark",
"Delete bookmark",
@ -1890,6 +1902,9 @@ struct EditCtx {
today: (i64, u32, u32),
seed: u64,
count: u64,
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
renamed_fields: Vec<(String, String)>,
}
impl EditCtx {
@ -1914,6 +1929,7 @@ impl EditCtx {
today: (1970, 1, 1),
seed,
count: 0,
renamed_fields: Vec::new(),
}
}
@ -1970,13 +1986,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
}
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
Edit::InsertPagesFrom { name, bytes, pages, at } => {
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
let src = open_source(name, bytes)?;
let pages = match pages {
Some(p) => p.clone(),
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
};
pdfcraft_organize::import_pages(doc, &src, &pages, *at)?;
let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?;
cx.renamed_fields = outcome.renamed_fields;
}
Edit::AddBookmark { parent, index, title, page } => {
pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
@ -3082,9 +3099,38 @@ impl Session {
if !js_off && uses_scripts(&edit) {
cx.js = Some(js::JsRunner::new(&next, &name));
}
cx.renamed_fields.clear();
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
// scripts were left alone) and a count in the undo step's label.
let renamed_note = |renamed: &[(String, String)]| {
(!renamed.is_empty()).then(|| {
let shown: Vec<String> = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect();
let more = renamed.len().saturating_sub(shown.len());
let more = if more > 0 { format!(", and {more} more") } else { String::new() };
format!(
"inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten",
renamed.len(),
if renamed.len() == 1 { "" } else { "s" },
shown.join(", ")
)
})
};
if let Some(note) = renamed_note(&cx.renamed_fields) {
next.note_repair(note);
}
// Only Insert from File renames fields; its label says so in a wording the interface
// translates (the count lives in the repair note).
let label = match (&edit, cx.renamed_fields.len()) {
(_, 0) => edit.label(),
(Edit::InsertPagesFrom { name, .. }, n) => Label::with(
if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" },
name.as_str(),
),
_ => edit.label(),
};
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
// every calculation (and a reset recalculates). They read the data, so it is brought up
// to date first.
@ -3145,7 +3191,7 @@ impl Session {
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
let scope = scope_of(&edit);
editor.undo.push((edit.label(), previous, previous_keys, scope));
editor.undo.push((label, previous, previous_keys, scope));
if editor.undo.len() > MAX_UNDO {
editor.undo.remove(0);
}
@ -3697,6 +3743,28 @@ impl Session {
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
/// position (missing or `None`: no password). The result is not encrypted.
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
}
/// The pre-flight over byte sources: what combining them, in order, would rename in each one
/// (whole files; page ranges are the combine's to apply). A source that cannot be opened
/// previews as nothing — its row already says why it cannot be combined.
pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec<Vec<(String, String)>> {
let docs: Vec<Option<pdfcraft_cos::Document>> =
sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect();
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect();
let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter();
// One preview per source, in step with it: a file that cannot be opened has none.
docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect()
}
/// [`Self::combine_unlocked`] reporting what the conflict resolution renamed.
pub fn combine_unlocked_reporting(
&self,
sources: &[CombineSource],
passwords: &[Option<&str>],
field_conflicts: pdfcraft_organize::FieldConflicts,
) -> Result<Combined, EditError> {
let docs = sources
.iter()
.enumerate()
@ -3717,8 +3785,9 @@ impl Session {
}
let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> =
sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect();
let out = pdfcraft_organize::combine_selected(&named)?;
self.write_new(&out)
let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?;
let bytes = self.write_new(&out.doc)?;
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
}
/// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one
@ -3727,7 +3796,17 @@ impl Session {
/// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes
/// and password; a source without a key is a file of its own.
pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
// Each file opened once: `file[i]` is source i's place in `docs`.
Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
}
/// [`Self::combine_grouped`] reporting what the conflict resolution renamed.
pub fn combine_grouped_reporting(
&self,
sources: &[CombineSource],
groups: &[u64],
passwords: &[Option<&str>],
field_conflicts: pdfcraft_organize::FieldConflicts,
) -> Result<Combined, EditError> {
let (mut keys, mut docs, mut file): (Vec<Option<u64>>, Vec<pdfcraft_cos::Document>, Vec<usize>) = (Vec::new(), Vec::new(), Vec::new());
for (i, (name, bytes, _)) in sources.iter().enumerate() {
let key = groups.get(i).copied();
@ -3761,8 +3840,9 @@ impl Session {
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
runs.push((*at, name.as_str(), d, p.as_deref()));
}
let out = pdfcraft_organize::combine_grouped(&runs)?;
self.write_new(&out)
let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?;
let bytes = self.write_new(&out.doc)?;
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
}
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).

View File

@ -430,15 +430,134 @@ fn combine_extract_split_and_insert_from_file() {
let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap();
assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::<Vec<_>>(), [(1, 2), (3, 3)]);
// Insert pages from a file, undoably, into the open document.
s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: other,
pages: Some(vec![1]),
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]);
assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf");
assert_eq!(page_texts(&s, id).len(), 3);
// Garbage sources fail cleanly.
let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 });
let bad = s.apply(
id,
Edit::InsertPagesFrom {
name: "junk.pdf".into(),
bytes: Arc::new(b"nope".to_vec()),
pages: None,
at: 0,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
);
assert!(matches!(bad, Err(EditError::Source(_))));
}
/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field).
fn form_with_field(name: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 4
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
bytes
}
/// Inserting pages of a file whose form names a field as the open document's: the incoming
/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is
/// not Combine), and nothing about renaming lands in the repair log.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log());
}
/// field is renamed to keep the two separate, the repair log says so (warning that scripts
/// were not rewritten), the undo step's label counts the renames, and undo puts the names back.
#[test]
fn insert_from_file_renames_colliding_fields_and_notes_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
s.apply(
id,
Edit::InsertPagesFrom {
name: "b.pdf".into(),
bytes: Arc::new(form_with_field("Text1")),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
assert!(
doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")),
"the note names the rename and the scripts caveat: {:?}",
doc.repair_log()
);
let label = s.undo(id).unwrap();
assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed");
let doc = s.get(id).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1"], "undo restores the single original field");
}
/// A combine reports the fields it renamed — the tool result tells callers what their scripts
/// must call the field now — and honours a caller's choice to merge instead.
#[test]
fn combine_reports_renamed_fields_and_can_merge() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> =
vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)];
let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
let cid = s.open_new("Combined.pdf", out.bytes).unwrap();
let doc = s.get(cid).unwrap();
let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Text1", "Text1-1"]);
let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty(), "merge renames nothing");
}
/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename
/// in each. A file that cannot be opened previews as nothing (its row already shows the problem).
#[test]
fn combine_field_conflicts_previews_over_bytes() {
let s = Session::new().with_clock(|| 1_700_000_000);
let sources: Vec<CombineSource> = vec![
("a.pdf".into(), Arc::new(form_with_field("Text1")), None),
("b.pdf".into(), Arc::new(form_with_field("Text1")), None),
("junk.pdf".into(), Arc::new(b"nope".to_vec()), None),
];
let preview = s.combine_field_conflicts(&sources, &[]);
assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]);
}
/// A minimal baseline JPEG (headers only; the data is embedded as is).
fn jpeg_bytes() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
@ -1572,7 +1691,17 @@ fn create_and_reduce() {
// A file with the same page imported twice shares nothing until reduced.
let (mut s2, id2) = session_with(1);
let src = Arc::new(fixture(1));
s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap();
s2.apply(
id2,
Edit::InsertPagesFrom {
name: "x".into(),
bytes: src.clone(),
pages: None,
at: 1,
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
},
)
.unwrap();
let full = s2.save_full_bytes(id2).unwrap();
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());

View File

@ -188,17 +188,58 @@ pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: H
c.copy_value(dst, o, None)
}
/// How colliding fully-qualified field names are resolved when pages are imported.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FieldConflicts {
/// The incoming field's own `/T` is renamed to the first free `Name-1`, `Name-2`… variant,
/// so it stays its own field (the default; Combine and Insert from File use it).
#[default]
Rename,
/// The older behaviour: a field that arrives with a taken name becomes one field with the
/// one already there. Duplicate, Replace, Extract and Split always use it.
Merge,
}
/// What [`import_pages_with`] did beyond placing the pages.
pub struct ImportOutcome {
/// The new pages, in order.
pub pages: Vec<ObjRef>,
/// Every field renamed to keep its name separate: old fully-qualified name → new one.
pub renamed_fields: Vec<(String, String)>,
}
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
/// position `at`, resolving field-name collisions per `conflicts`. Returns the new page
/// references in order and what the conflict resolution renamed.
///
/// Resources identical to ones already in `dst` (the same fonts, images, colour profiles) are
/// shared rather than stored twice (see `dedupe`).
pub fn import_pages_with(
dst: &mut Document,
src: &Document,
src_pages: &[usize],
at: usize,
conflicts: FieldConflicts,
) -> Result<ImportOutcome, OrganizeError> {
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?;
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
crate::dedupe::dedupe_resources(dst, &created, true);
Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields })
}
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
/// position `at`. Returns the new page references in order.
///
/// Fields keep their names ([`FieldConflicts::Merge`]): this is what Duplicate and Replace
/// Pages use, where the incoming fields are copies of the document's own and renaming them
/// would silently change what the form exports. Combine and Insert from File, which bring in
/// another document's fields, use [`import_pages_with`] and [`FieldConflicts::Rename`].
///
/// Resources identical to ones already in `dst` (the same fonts, images, colour profiles) are
/// shared rather than stored twice (see `dedupe`).
pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Vec<ObjRef>, OrganizeError> {
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
let pages = import_pages_mapped(dst, src, src_pages, at)?.pages;
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
crate::dedupe::dedupe_resources(dst, &created, true);
Ok(pages)
import_pages_with(dst, src, src_pages, at, FieldConflicts::Merge).map(|o| o.pages)
}
/// What `import_pages_mapped` copied.
@ -209,10 +250,18 @@ struct Imported {
page_map: HashMap<ObjRef, ObjRef>,
/// Every source object copied → its copy.
objects: HashMap<ObjRef, ObjRef>,
/// Fields renamed to keep their names separate (old → new fully-qualified name).
renamed_fields: Vec<(String, String)>,
}
/// `import_pages`, also returning what was copied from where.
fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Imported, OrganizeError> {
fn import_pages_mapped(
dst: &mut Document,
src: &Document,
src_pages: &[usize],
at: usize,
conflicts: FieldConflicts,
) -> Result<Imported, OrganizeError> {
let source = walk(src)?;
if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) {
return Err(OrganizeError::NoSuchPage(*bad));
@ -336,13 +385,17 @@ fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize],
new_pages.push(new);
}
copier.fix_annotations(dst, &annot_pages)?;
let renamed_fields = match conflicts {
FieldConflicts::Rename => resolve_field_conflicts(dst, &copier.fields)?,
FieldConflicts::Merge => Vec::new(),
};
register_fields(dst, &copier.fields)?;
register_layers(dst, src, &copier.ocgs)?;
let at = at.min(existing.len());
let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect();
existing.splice(at..at, inserted);
rebuild(dst, &existing)?;
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map })
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map, renamed_fields })
}
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
@ -444,6 +497,189 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]
Ok(())
}
/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names
/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of
/// blurring into the destination's field of the same name (fields of one fully-qualified name
/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`…
/// variant, so everything below it keeps its place in the tree. Returns what was renamed
/// (old fully-qualified name → new one), in order.
fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result<Vec<(String, String)>, OrganizeError> {
let mut taken = taken_field_names(dst)?;
let planned = plan_renames(dst, fields, &mut taken);
let mut renamed = Vec::new();
for (node, new_t, old, new) in planned {
dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?;
renamed.push((old, new));
}
Ok(renamed)
}
/// What importing `src` into `dst` would rename, computed without changing either document:
/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier
/// field of `src` itself uses) is renamed to the first free variant, exactly as the import
/// would. Unreadable structure previews as nothing: the import itself will say why it failed.
pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> {
let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() };
let Ok(roots) = field_roots(src) else { return Vec::new() };
plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect()
}
/// What combining `sources`, in order, would rename in each one: the whole list is simulated
/// with the same first-free-variant rule the combine uses, so a later file's preview sees the
/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will
/// say why it fails); non-colliding names still count as taken for the files after them.
pub fn combine_field_conflicts(sources: &[&Document]) -> Vec<Vec<(String, String)>> {
let mut taken = std::collections::HashSet::new();
sources
.iter()
.map(|src| match field_roots(src) {
Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(),
Err(_) => Vec::new(),
})
.collect()
}
/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate
/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same
/// import collides with an earlier one), and a node already taken is renamed to the first free
/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored
/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name.
/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe.
fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet<String>) -> Vec<(ObjRef, String, String, String)> {
let mut planned = Vec::new();
let mut seen = std::collections::HashSet::new();
for &root in roots {
plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned);
}
planned
}
/// One node of [`plan_renames`].
fn plan_renames_under(
doc: &Document,
node: ObjRef,
prefix: &str,
taken: &mut std::collections::HashSet<String>,
seen: &mut std::collections::HashSet<ObjRef>,
depth: u8,
planned: &mut Vec<(ObjRef, String, String, String)>,
) {
if depth > 32 || !seen.insert(node) {
return;
}
let Some(d) = doc.get(node).as_dict().cloned() else { return };
let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
if !name.is_empty() {
let fqn = qualified(prefix, &name);
if taken.contains(&fqn) {
let mut n = 1u32;
let mut variant;
while {
variant = format!("{name}-{n}");
taken.contains(&qualified(prefix, &variant))
} {
n += 1;
}
let new_fqn = qualified(prefix, &variant);
planned.push((node, variant.clone(), fqn, new_fqn.clone()));
taken.insert(new_fqn);
name = variant;
} else {
taken.insert(fqn);
}
}
let prefix = qualified_optional(prefix, &name);
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for kid in kids.iter().filter_map(|k| k.as_ref()) {
plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned);
}
}
/// The field names in use in `dst`: every name in its form, and every name hanging only off a
/// page's widgets — a field the form never lists still owns its name.
fn taken_field_names(dst: &Document) -> Result<std::collections::HashSet<String>, OrganizeError> {
let mut taken = std::collections::HashSet::new();
let mut seen = std::collections::HashSet::new();
for root in field_roots(dst)? {
collect_names(dst, root, "", &mut seen, 0, &mut taken);
}
Ok(taken)
}
/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and
/// the fields behind widgets on pages (climbed to their root, for forms that never registered
/// them).
fn field_roots(doc: &Document) -> Result<Vec<ObjRef>, OrganizeError> {
let mut roots: Vec<ObjRef> = doc
.root()
.and_then(|r| doc.get(r).as_dict().cloned())
.and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f)))
.and_then(|f| f.as_dict().cloned())
.and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l)))
.and_then(|l| l.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|f| f.as_ref())
.collect();
for (page, _) in walk(doc)? {
let Some(d) = doc.get(page).as_dict().cloned() else { continue };
let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for a in annots.iter() {
let Some(w) = doc.resolve(a).as_dict().cloned() else { continue };
if w.name(b"Subtype") != Some(b"Widget") {
continue;
}
// Walk up to the field the widget belongs to, however deep it sits; a widget that
// is its own field is its own root.
let Some(mut root) = a.as_ref() else { continue };
let mut up = 0;
while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) {
if up >= 32 || root == parent {
break;
}
root = parent;
up += 1;
}
roots.push(root);
}
}
Ok(roots)
}
/// Record the fully-qualified names of one field tree. Cycle- and depth-safe.
fn collect_names(
dst: &Document,
node: ObjRef,
prefix: &str,
seen: &mut std::collections::HashSet<ObjRef>,
depth: u8,
out: &mut std::collections::HashSet<String>,
) {
if depth > 32 || !seen.insert(node) {
return;
}
let Some(d) = dst.get(node).as_dict().cloned() else { return };
let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
let prefix = qualified_optional(prefix, &name);
if !name.is_empty() {
out.insert(prefix.clone());
}
let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
for kid in kids.iter().filter_map(|k| k.as_ref()) {
collect_names(dst, kid, &prefix, seen, depth + 1, out);
}
}
/// `prefix.name`, or `name` at the top level.
fn qualified(prefix: &str, name: &str) -> String {
if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") }
}
/// The prefix a node's children hang under: its own name, when it has one.
fn qualified_optional(prefix: &str, name: &str) -> String {
if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) }
}
/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed).
fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> {
if fields.is_empty() {
@ -478,8 +714,9 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize
/// source declares (PDF/X: its output intents and identification, #263).
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
let mut out = Document::new_empty();
// One source: nothing to deduplicate.
let imported = import_pages_mapped(&mut out, src, pages, 0)?;
// One source: nothing to deduplicate. Its fields keep their names (a source with two
// same-named field roots stays one field, as it was), so Extract and Split never rename.
let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?;
for key in crate::INFO_KEYS {
if let Some(v) = crate::info(src, key) {
crate::set_info(&mut out, key, &v)?;
@ -527,24 +764,43 @@ pub fn split(src: &Document, by: &SplitBy) -> Result<Vec<Document>, OrganizeErro
split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::<Vec<_>>())).collect()
}
/// What combining produced, beyond the document: the fields renamed to keep colliding names
/// separate (old fully-qualified name → new one), in order across all sources.
pub struct CombineOutcome {
/// The combined document.
pub doc: Document,
/// Every field renamed while combining.
pub renamed_fields: Vec<(String, String)>,
}
/// Combine whole documents, in order, into a new document. Each source gets a top-level
/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files.
/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level
/// attachments are carried over. When every source declares the same print standard (PDF/X
/// with the same output intent), so does the result.
pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError> {
combine_with(sources, FieldConflicts::Rename).map(|o| o.doc)
}
/// [`combine`] reporting what the conflict resolution renamed.
pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect();
combine_selected(&all)
combine_selected_with(&all, conflicts)
}
/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order;
/// `None` for all of them). Bookmarks that point at pages left out lose their destination.
pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result<Document, OrganizeError> {
let mut combiner = Combiner::new();
combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc)
}
/// [`combine_selected`] reporting what the conflict resolution renamed.
pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
let mut combiner = Combiner::with_field_conflicts(conflicts);
for (title, src, chosen) in sources {
combiner.add(title, src, *chosen)?;
}
combiner.finish()
combiner.finish_with_renames()
}
/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`]
@ -560,6 +816,10 @@ pub struct Combiner {
standard: Option<Standard>,
/// Whether every source so far declares the first source's print standard.
agreed: bool,
/// How colliding field names are resolved as each source is added.
field_conflicts: FieldConflicts,
/// What adding sources renamed (old → new fully-qualified field name), in order.
renamed_fields: Vec<(String, String)>,
}
/// The first source's print standard (see [`Combiner`]).
@ -578,9 +838,27 @@ impl Default for Combiner {
}
impl Combiner {
/// A combine with no sources yet.
/// A combine with no sources yet, renaming colliding field names as sources are added.
pub fn new() -> Self {
Self { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), standard: None, agreed: true }
Self::with_field_conflicts(FieldConflicts::Rename)
}
/// [`Combiner::new`] resolving colliding field names as `conflicts` says.
pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self {
Self {
out: Document::new_empty(),
marks: Vec::new(),
attachments: Vec::new(),
standard: None,
agreed: true,
field_conflicts,
renamed_fields: Vec::new(),
}
}
/// What adding sources has renamed so far (old → new fully-qualified field name).
pub fn renamed_fields(&self) -> &[(String, String)] {
&self.renamed_fields
}
/// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far,
@ -597,7 +875,8 @@ impl Combiner {
None => (0..n).collect(),
};
let at = crate::page_count(&self.out)?;
let imported = import_pages_mapped(&mut self.out, src, &pages, at)?;
let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?;
self.renamed_fields.extend(imported.renamed_fields);
if let Some(first) = imported.pages.first() {
let outline = source_outline(src, imported.page_map);
self.marks.push(Mark { title: title.to_string(), page: *first, outline });
@ -617,7 +896,12 @@ impl Combiner {
/// The combined document: the pages as added, the print standard of the first source when every
/// source declares the same one, shared resources stored once, then the bookmarks and attachments.
pub fn finish(self) -> Result<Document, OrganizeError> {
let Combiner { mut out, marks, attachments, standard, agreed } = self;
self.finish_with_renames().map(|o| o.doc)
}
/// [`Combiner::finish`] reporting what the combine renamed.
pub fn finish_with_renames(self) -> Result<CombineOutcome, OrganizeError> {
let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self;
if let Some(first) = standard.filter(|_| agreed) {
crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?;
}
@ -626,7 +910,7 @@ impl Combiner {
crate::dedupe::dedupe_resources(&mut out, &all, false);
add_outline(&mut out, &marks)?;
set_attachments(&mut out, attachments)?;
Ok(out)
Ok(CombineOutcome { doc: out, renamed_fields })
}
}
@ -641,6 +925,11 @@ pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>);
/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its
/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources).
pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc)
}
/// [`combine_grouped`] reporting what the conflict resolution renamed.
pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result<CombineOutcome, OrganizeError> {
// Each file's pages, in the order its runs list them, and each run's place in that list.
struct File<'a> {
title: &'a str,
@ -675,12 +964,14 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
let mut marks = Vec::new();
let mut attachments = Vec::new();
let mut copies: Vec<Vec<ObjRef>> = Vec::with_capacity(files.len());
let mut renamed_fields = Vec::new();
let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap<ObjRef, ObjRef>)> = None;
let mut agreed = true;
for (_, file) in &files {
let at = crate::page_count(&out)?;
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at)?;
let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?;
copies.push(imported.pages.clone());
renamed_fields.extend(imported.renamed_fields);
if let Some(first) = imported.pages.first() {
marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) });
}
@ -706,7 +997,7 @@ pub fn combine_grouped(runs: &[Run<'_>]) -> Result<Document, OrganizeError> {
crate::dedupe::dedupe_resources(&mut out, &all, false);
add_outline(&mut out, &marks)?;
set_attachments(&mut out, attachments)?;
Ok(out)
Ok(CombineOutcome { doc: out, renamed_fields })
}
/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`.

View File

@ -25,7 +25,9 @@ pub mod view;
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
pub use dedupe::dedupe_resources;
pub use import::{
Combiner, Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges,
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, page_as_form, split,
split_ranges,
};
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
pub use outline::{

View File

@ -1726,3 +1726,365 @@ fn a_repeated_widget_joins_an_indirect_kids_array() {
let kids = out.resolve(field.get(b"Kids").unwrap()).as_array().cloned().unwrap();
assert_eq!(kids.len(), 3, "both original widgets and the repeat's own: {kids:?}");
}
/// A one-page document whose form holds one text field named `name` (a widget that is its own
/// field, the smallest form a page can carry).
fn doc_with_field(name: &str) -> Document {
doc_with_fields(&[name])
}
/// A one-page document whose form holds one widget field per name, in order.
fn doc_with_fields(names: &[&str]) -> Document {
let first = 5; // object number of the first field
let refs: Vec<String> = names.iter().enumerate().map(|(i, _)| format!("{} 0 R", first + i)).collect();
let mut b: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [{}] >> >>", refs.join(" ")), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs.join(" ")), // 3
"<< /Title (form) >>".into(), // 4
];
for name in names.iter() {
b.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>")); // 5 + i
}
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// The /T names of a document's /AcroForm /Fields, in order.
fn form_field_names(doc: &Document) -> Vec<String> {
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
return Vec::new();
};
form.get(b"Fields")
.map(|f| doc.resolve(f))
.and_then(|f| f.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|f| {
doc.resolve(f)
.as_dict()
.and_then(|d| d.get(b"T").cloned())
.map(|t| doc.resolve(&t))
.and_then(|t| t.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned()))
.unwrap_or_default()
})
.collect()
}
/// Two documents whose forms both name a field Text1 cannot just be put together: fields of
/// one fully-qualified name are one field, so the values would blur into it. The incoming
/// field is renamed to the first free variant, Text1-1.
#[test]
fn import_renames_a_colliding_field_name() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"]);
}
/// The rename takes the first free variant: with Text1 and Text1-1 already in the form, a
/// colliding Text1 becomes Text1-2 (not Text1-1, which would collide again).
#[test]
fn import_renames_to_the_first_free_variant() {
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1", "Text1-2"]);
}
/// A one-page document whose form holds one text field whose `/T` is a UTF-16BE hex string —
/// the standard encoding for non-ASCII names — decoding to `name`.
fn doc_with_field_utf16(name: &str) -> Document {
let t: String = std::iter::once(0xFEFFu16).chain(name.encode_utf16()).map(|u| format!("{u:04X}")).collect();
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Title (form) >>".into(), // 4
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T <{t}> /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 5
];
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// The decoded `/T` names of a document's /AcroForm /Fields, in order (whatever encoding each
/// was written in).
fn decoded_form_field_names(doc: &Document) -> Vec<String> {
let Some(form) = catalog(doc).get(b"AcroForm").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else {
return Vec::new();
};
form.get(b"Fields")
.map(|f| doc.resolve(f))
.and_then(|f| f.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|f| {
doc.resolve(f)
.as_dict()
.and_then(|d| d.get(b"T").cloned())
.map(|t| doc.resolve(&t))
.and_then(|t| t.as_string().map(|s| s.to_text()))
.unwrap_or_default()
})
.collect()
}
/// A field's name is its text, not the bytes it was written in: a destination field named in
/// PDFDocEncoding and an incoming UTF-16BE field that decode to the same name are one name,
/// so the incoming field is renamed out of the way (not silently blurred into the other).
#[test]
fn import_renames_by_decoded_name_whatever_its_encoding() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field_utf16("Text1");
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(decoded_form_field_names(&dst), ["Text1", "Text1-1"]);
}
/// Renaming a non-ASCII name keeps it readable: the new `/T` stores the new name as text
/// (UTF-16BE again), never as mojibake of the old bytes.
#[test]
fn a_renamed_non_ascii_name_stays_readable() {
let mut dst = doc_with_field_utf16("Förm");
let src = doc_with_field_utf16("Förm");
let out = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Förm".to_string(), "Förm-1".to_string())]);
assert_eq!(decoded_form_field_names(&dst), ["Förm", "Förm-1"]);
}
/// A one-page document whose form holds one field tree: a root field `root` with one child
/// field `kid` (a merged field-and-widget, as page tools make them).
fn doc_with_tree(root: &str, kid: &str) -> Document {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), // 3
"<< /Title (form) >>".into(), // 4
format!("<< /FT /Tx /T ({root}) /Kids [6 0 R] >>"), // 5
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({kid}) /Parent 5 0 R /Rect [60 60 200 80] /P 3 0 R >>"), // 6
];
open(build(&b, "/Root 1 0 R /Info 4 0 R"))
}
/// A field's name is its place in the tree, not just its own `/T`: an incoming field called
/// x.b collides with the child b of the destination's field x, and is renamed out of the way.
#[test]
fn import_renames_a_nested_field_whose_full_name_collides() {
let mut dst = doc_with_tree("x", "b");
let src = doc_with_field("x.b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["x", "x.b-1"]);
}
/// The collision may sit below the top level: the destination holds a field literally named
/// a.b, and the incoming tree a ▸ b walks into it. Only the colliding child is renamed — the
/// incoming root a never collided, so it keeps its name.
#[test]
fn import_renames_the_colliding_child_not_the_whole_tree() {
let mut dst = doc_with_field("a.b");
let src = doc_with_tree("a", "b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["a.b", "a"], "the incoming root keeps its name");
// The incoming tree is the second /Fields entry; its child was renamed.
let fields = catalog(&dst)
.get(b"AcroForm")
.map(|f| dst.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| dst.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let kid = dst.resolve(&fields[1]).as_dict().unwrap().get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
let kid_t = dst.resolve(&kid[0]).as_dict().unwrap().get(b"T").cloned().unwrap();
let resolved = dst.resolve(&kid_t);
let name = resolved.as_string().unwrap();
assert_eq!(String::from_utf8_lossy(&name.bytes), "b-1", "the child's /T is renamed, the FQN follows the root");
}
/// A field name is taken even when it hangs only off a page's annotations: a widget that is
/// its own field, never registered in `/AcroForm /Fields`, still names a field, and an import
/// must not silently blur into it.
#[test]
fn import_renames_away_from_an_orphaned_widgets_name() {
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 (no /AcroForm)
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3
"<< /Title (orphan) >>".into(), // 4
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Text1) /V (x) /Rect [60 60 200 80] /P 3 0 R >>".into(), // 5
];
let mut dst = open(build(&b, "/Root 1 0 R /Info 4 0 R"));
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1-1"], "the orphaned Text1 must count as taken");
}
/// Callers can keep the older behaviour — an incoming field of a taken name becomes one field
/// with the one already there — by asking to merge. Nothing is renamed.
#[test]
fn import_can_merge_into_same_named_fields_instead() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Merge).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1"]);
assert!(outcome.renamed_fields.is_empty(), "merge renames nothing");
}
/// Duplicating a page copies the document's own fields: the copies keep their names (one field
/// shown twice, as before field-conflict renaming existed) instead of silently becoming Text1-1.
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut doc = doc_with_field("Text1");
crate::duplicate_pages(&mut doc, &[0]).unwrap();
assert_eq!(crate::page_count(&doc).unwrap(), 2);
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
}
/// Replace Pages keeps the field names too.
#[test]
fn replacing_a_page_renames_no_fields() {
let mut doc = doc_with_field("Text1");
crate::replace_pages(&mut doc, &[0], &doc_with_field("Text1"), &[0]).unwrap();
assert!(form_field_names(&doc).iter().all(|n| n == "Text1"), "{:?}", form_field_names(&doc));
}
/// Extract and Split copy one document: two same-named field roots (malformed but common) stay
/// one field by name rather than being split apart as Name and Name-1.
#[test]
fn extract_and_split_keep_same_named_fields_together() {
let src = doc_with_fields(&["Text1", "Text1"]);
let out = extract_pages(&src, &[0]).unwrap();
assert_eq!(form_field_names(&out), ["Text1", "Text1"]);
for part in split(&src, &SplitBy::PageCount(1)).unwrap() {
assert_eq!(form_field_names(&part), ["Text1", "Text1"]);
}
}
/// An import says what it renamed, so callers can tell the user — and their scripts — that a
/// field they knew by one name now goes by another.
#[test]
fn import_reports_the_fields_it_renamed() {
let mut dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
let outcome = import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(outcome.renamed_fields, [("Text1".to_string(), "Text1-2".to_string())]);
assert_eq!(outcome.pages.len(), 1);
}
/// A renamed tree survives a save and reopen: the parent keeps its new name, the child still
/// hangs under it with its own name unchanged, and an independent parser reads the same form.
#[test]
fn a_renamed_field_tree_survives_a_save_and_reopen() {
let mut dst = doc_with_tree("a", "b");
let src = doc_with_tree("a", "b");
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
let out = full_roundtrip(&dst);
assert_eq!(form_field_names(&out), ["a", "a-1"], "the incoming root was renamed");
let fields = catalog(&out)
.get(b"AcroForm")
.map(|f| out.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| out.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let root = out.resolve(&fields[1]).as_dict().cloned().unwrap();
let kids = root.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap();
let kid = out.resolve(&kids[0]).as_dict().cloned().unwrap();
assert_eq!(kid.reference(b"Parent"), fields[1].as_ref(), "the child still hangs under the renamed root");
let kid_t = kid.get(b"T").cloned().unwrap();
let resolved = out.resolve(&kid_t);
assert_eq!(String::from_utf8_lossy(&resolved.as_string().unwrap().bytes), "b", "the child keeps its own name");
}
/// The pre-scan says what an import of `src` into `dst` would rename, without changing
/// anything — the Combine tab shows it before anything is combined.
#[test]
fn field_name_conflicts_previews_what_an_import_renames() {
let dst = doc_with_fields(&["Text1", "Text1-1"]);
let src = doc_with_field("Text1");
assert_eq!(field_name_conflicts(&dst, &src), [("Text1".to_string(), "Text1-2".to_string())]);
let apart = doc_with_field("Signature1");
assert!(field_name_conflicts(&dst, &apart).is_empty(), "nothing collides, nothing to preview");
}
/// The collision is not only with the destination: two incoming fields of one name would blur
/// into each other just the same, so the second is renamed too.
#[test]
fn import_renames_a_field_colliding_with_another_incoming_field() {
let mut dst = doc_with_field("Other");
let src = doc_with_fields(&["Text1", "Text1"]);
import_pages_with(&mut dst, &src, &[0], 1, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]);
}
/// A page listed twice shows its widget on both copies — also when the field was renamed: both
/// pages' widgets belong to the one renamed field, and no second Text1 appears.
#[test]
fn a_repeated_page_joins_the_renamed_field() {
let mut dst = doc_with_field("Text1");
let src = doc_with_field("Text1");
import_pages_with(&mut dst, &src, &[0, 0], 2, FieldConflicts::Rename).unwrap();
assert_eq!(form_field_names(&dst), ["Text1", "Text1-1"], "one field, renamed once");
let fields = catalog(&dst)
.get(b"AcroForm")
.map(|f| dst.resolve(f))
.and_then(|f| f.as_dict().cloned())
.unwrap()
.get(b"Fields")
.map(|l| dst.resolve(l))
.and_then(|l| l.as_array().cloned())
.unwrap();
let renamed = fields[1].clone();
for page in [1, 2] {
let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field");
}
}
/// Combining forms keeps same-named fields separate — the second file's colliding field is
/// renamed — and the combine says what it renamed.
#[test]
fn combine_renames_colliding_field_names_and_reports_them() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let out = combine_selected_with(&[("a", &a as &Document, None), ("b", &b, None)], FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
let merged = combine_selected_with(&[("a", &a, None), ("b", &b, None)], FieldConflicts::Merge).unwrap();
assert!(merged.renamed_fields.is_empty());
assert_eq!(form_field_names(&merged.doc), ["Text1", "Text1"], "merge keeps the older behaviour");
}
/// The grouped combine (a file's pages split around others') renames colliding names between
/// files just the same, while a file combined with itself once keeps its fields whole.
#[test]
fn combine_grouped_renames_colliding_field_names() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let runs: Vec<Run<'_>> = vec![(0, "a", &a, None), (1, "b", &b, None)];
let out = combine_grouped_with(&runs, FieldConflicts::Rename).unwrap();
assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(form_field_names(&out.doc), ["Text1", "Text1-1"]);
let split: Vec<Run<'_>> = vec![(0, "a", &a, Some(&[0])), (0, "a", &a, None), (1, "b", &b, None)];
let once = combine_grouped_with(&split, FieldConflicts::Rename).unwrap();
assert_eq!(
once.renamed_fields,
[("Text1".to_string(), "Text1-1".to_string())],
"the grouped file is copied once; only b's colliding field renames"
);
}
/// The pre-flight scan over a whole combine list: what each file's fields would be renamed,
/// in order, exactly as the combine would rename them — the Combine tab shows this before
/// anything is combined.
#[test]
fn combine_field_conflicts_previews_each_file_in_order() {
let a = doc_with_field("Text1");
let b = doc_with_field("Text1");
let c = doc_with_fields(&["Text1", "Other"]);
let preview = combine_field_conflicts(&[&a, &b, &c]);
assert_eq!(preview[0], [], "the first file collides with nothing");
assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]);
assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename");
}

View File

@ -251,6 +251,43 @@ pub enum Lock {
Permissions,
}
/// The combine list's field-name conflicts, computed once per list (it opens the files):
/// for each file, the renames combining would make of its form fields. Keyed by the file ids
/// and their lock state, so unlocking a file refreshes it.
#[derive(Default, Debug)]
pub struct ConflictPreview {
key: (Vec<u64>, Vec<bool>),
per_file: Vec<Vec<(String, String)>>,
}
impl ConflictPreview {
/// Bring the preview in step with `files`, opening them through the engine when the list
/// changed. A file split into parts is combined as one file, once — each group's first
/// entry stands for it, as the combine does — and every part shows the file's preview.
/// A file that cannot be opened previews as nothing (its row says why).
fn refresh(&mut self, session: &pdfcraft_engine::Session, files: &[CombineFile]) {
let key = (files.iter().map(|f| f.id).collect(), files.iter().map(|f| f.password.is_some()).collect());
if self.key == key && self.per_file.len() == files.len() {
return;
}
let mut groups: Vec<u64> = Vec::new();
let mut sources: Vec<pdfcraft_engine::CombineSource> = Vec::new();
let mut passwords: Vec<Option<&str>> = Vec::new();
for f in files {
if groups.contains(&f.group) {
continue;
}
groups.push(f.group);
sources.push((f.name.clone(), f.bytes.clone(), None));
passwords.push(f.password.as_ref().map(|p| p.0.as_str()));
}
let per_group = session.combine_field_conflicts(&sources, &passwords);
self.per_file =
files.iter().map(|f| groups.iter().position(|g| *g == f.group).and_then(|at| per_group.get(at)).cloned().unwrap_or_default()).collect();
self.key = key;
}
}
/// Unlock…'s password box: the rows it unlocks and what has been typed.
#[derive(Clone, Debug)]
struct UnlockPrompt {
@ -550,6 +587,10 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let files_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
// Ranges are checked before anything below reads them.
let checks: Vec<Result<usize, String>> = app.combine_draft.iter_mut().map(CombineFile::selection).collect();
// Same-named form fields across the files are renamed when combining: preview it once per
// list, so the Warnings column can say so before anything is combined.
app.combine_conflicts.refresh(&app.session, &app.combine_draft);
let conflicts = app.combine_conflicts.per_file.clone();
let n = app.combine_draft.len();
let ids: Vec<u64> = app.combine_draft.iter().map(|f| f.id).collect();
app.combine_tab.selected.retain(|id| ids.contains(id));
@ -741,7 +782,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
events.action = Some(action);
}
} else {
let table_events = table(app, ui, &t, &checks, &selected);
let table_events = table(app, ui, &t, &checks, &selected, &conflicts);
events.range_focused |= table_events.range_focused;
events.range_changed |= table_events.range_changed;
if table_events.action.is_some() {
@ -1178,7 +1219,14 @@ fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
/// The file table.
fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result<usize, String>], selected: &[bool]) -> TableEvents {
fn table(
app: &mut PdfCraftApp,
ui: &mut egui::Ui,
t: &Tokens,
checks: &[Result<usize, String>],
selected: &[bool],
conflicts: &[Vec<(String, String)>],
) -> TableEvents {
let mut events = TableEvents::default();
// The same file twice is allowed (e.g. a cover sheet), but probably a mistake.
// (A file's parts are the one file, not copies.)
@ -1196,7 +1244,7 @@ fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result<
app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1);
}
let widths = app.combine_columns.resolved(available);
let rows = Rows { checks, selected, twice: &twice, dragging, sort: app.combine_tab.sort };
let rows = Rows { checks, selected, twice: &twice, conflicts, dragging, sort: app.combine_tab.sort };
let mut heading_event = None;
let mut shown = None;
egui::ScrollArea::horizontal()
@ -1233,6 +1281,8 @@ struct Rows<'a> {
checks: &'a [Result<usize, String>],
selected: &'a [bool],
twice: &'a [bool],
/// What combining would rename of this file's form fields (the field-name conflict preview).
conflicts: &'a [Vec<(String, String)>],
dragging: bool,
sort: Option<(SortKey, bool)>,
}
@ -1423,6 +1473,19 @@ fn table_body(
if rows.twice.get(i).copied().unwrap_or(false) {
warnings.push(tl!("Added more than once").to_string());
}
if let Some(renames) = rows.conflicts.get(i).filter(|r| !r.is_empty()) {
let shown: Vec<String> = renames.iter().take(3).map(|(old, new)| format!("{old} → {new}")).collect();
let list = shown.join(", ");
warnings.push(crate::i18n::fmt(
&crate::i18n::trn(
crate::i18n::current(),
renames.len() as u64,
"{n} field name collides with earlier files and will be renamed: {list}",
"{n} field names collide with earlier files and will be renamed: {list}",
),
&[("list", &list)],
));
}
let (icon, colour, first) = match (errors.first(), warnings.first()) {
(Some(e), _) => ("circle-x", ERROR, e.clone()),
(None, Some(w)) => ("triangle-alert", WARNING, w.clone()),

View File

@ -344,7 +344,7 @@ impl PdfCraftApp {
self.notify_tr("A document needs at least one page: copy instead");
return;
}
self.page_clipboard = Some(crate::PageClip { name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
self.page_clipboard = Some(crate::PageClip { from: id, name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
if cut {
self.apply_edit(Edit::DeletePages { pages: pages.clone() });
}
@ -365,7 +365,17 @@ impl PdfCraftApp {
let Some(i) = self.active else { return };
let at = self.views[i].target_pages().into_iter().max().map_or(0, |p| p + 1);
let count = clip.pages.len();
if self.apply_edit(Edit::InsertPagesFrom { name: clip.name.clone(), bytes: clip.bytes.clone(), pages: Some(clip.pages.clone()), at }) {
// Pasting into the document the pages came from is a duplicate (or, after Cut, a move):
// its fields keep their names. Pasting into another document renames colliding ones.
let field_conflicts =
if self.views[i].id == clip.from { pdfcraft_engine::FieldConflicts::Merge } else { pdfcraft_engine::FieldConflicts::Rename };
if self.apply_edit(Edit::InsertPagesFrom {
name: clip.name.clone(),
bytes: clip.bytes.clone(),
pages: Some(clip.pages.clone()),
at,
field_conflicts,
}) {
self.views[i].select_pages(&(at..at + count).collect::<Vec<_>>());
}
}

View File

@ -506,7 +506,13 @@ impl PdfCraftApp {
self.session.convert_to_pdf(&name, &Arc::new(bytes)).and_then(|(_, pdf)| Ok((self.session.page_count_of(&name, &pdf)?, pdf)));
match converted {
Ok((pages, bytes)) => {
if self.apply_edit(Edit::InsertPagesFrom { name, bytes, pages: None, at }) {
if self.apply_edit(Edit::InsertPagesFrom {
name,
bytes,
pages: None,
at,
field_conflicts: pdfcraft_engine::FieldConflicts::Rename,
}) {
at = at.saturating_add(pages);
}
}

View File

@ -1869,6 +1869,8 @@
{month} {y} {month} {y}
Change {key} تغيير {key}
Insert pages from {name} إدراج صفحات من {name}
Insert pages from {name} — 1 field renamed إدراج صفحات من {name} — تمت إعادة تسمية حقل واحد
Insert pages from {name} — fields renamed إدراج صفحات من {name} — تمت إعادة تسمية حقول
Fill in {name} تعبئة {name}
Set the image of {name} تعيين صورة {name}
Edit script of {name} تحرير سكربت {name}
@ -2035,6 +2037,7 @@
Couldn't import the signature image: {e} تعذّر استيراد صورة التوقيع: {e}
@plural {n} page|{n} pages {n} صفحة|{n} صفحة|{n} صفحتان|{n} صفحات|{n} صفحة|{n} صفحة
@plural {n} field|{n} fields {n} حقل|{n} حقل|{n} حقلان|{n} حقول|{n} حقلًا|{n} حقل
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} يتعارض {n} من أسماء الحقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض اسم {n} حقل مع الملفات السابقة وسيتم تغيير اسمه: {list}|يتعارض اسما {n} حقل مع الملفات السابقة وسيتم تغيير اسميهما: {list}|تتعارض أسماء {n} حقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض {n} اسم حقل مع الملفات السابقة وسيتم تغيير أسمائها: {list}|يتعارض {n} من أسماء الحقول مع الملفات السابقة وسيتم تغيير أسمائها: {list}
Create PDF from multiple files… إنشاء PDF من عدة ملفات…
Optimizing… removing user data جارٍ التحسين… إزالة بيانات المستخدم
Optimizing… image {d} of {t} جارٍ التحسين… الصورة {d} من {t}

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

View File

@ -1960,6 +1960,8 @@
Images and text (converted to PDF) Изображения и текст (конвертирани в PDF)
Import {name} Импортирай {name}
Insert pages from {name} Вмъкни страници от {name}
Insert pages from {name} — 1 field renamed Вмъкни страници от {name} — 1 поле е преименувано
Insert pages from {name} — fields renamed Вмъкни страници от {name} — полета са преименувани
Items marked Ready work today. Елементите, отбелязани като „Готово“, работят още сега.
JavaScript JavaScript
JavaScript, e.g. getField("total").value JavaScript, напр. getField("total").value
@ -2080,6 +2082,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA формуляр: полетата и XFA данните му се поддържат синхронизирани, така че другите програми за преглед показват попълненото от вас.
@plural {n} page|{n} pages {n} страница|{n} страници
@plural {n} field|{n} fields {n} поле|{n} полета
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Име на {n} поле се разминава с по-ранните файлове и ще бъде преименувано: {list}|Имена на {n} полета се разминават с по-ранните файлове и ще бъдат преименувани: {list}
Created a PDF from {n} files Създаден е PDF от {n} файла
PDF, images and text PDF, изображения и текст
Insert a file before page {label} Вмъкни файл преди страница {label}

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

View File

@ -171,6 +171,7 @@
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected Vybrán {n} objekt|Vybrány {n} objekty|Vybráno {n} objektů
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} název pole koliduje s dřívějšími soubory a bude přejmenován: {list}|Názvy {n} polí kolidují s dřívějšími soubory a budou přejmenovány: {list}|{n} názvů polí koliduje s dřívějšími soubory a bude přejmenováno: {list}
Drag the selection to move it. Esc clears the selection. Přetažením výběr přesunete. Esc zruší výběr.
Shift-click to select several objects. Drag empty space to select an area. Kliknutím se Shiftem vyberete více objektů. Tažením v prázdném místě vyberete oblast.
Select at most 1000 objects at a time. Vyberte nejvýše 1000 objektů najednou.
@ -1916,6 +1917,8 @@ key Space Mezerník
{month} {y} {month} {y}
Change {key} Změnit: {key}
Insert pages from {name} Vložit stránky ze souboru {name}
Insert pages from {name} — 1 field renamed Vložit stránky ze souboru {name} — 1 pole přejmenováno
Insert pages from {name} — fields renamed Vložit stránky ze souboru {name} — pole přejmenována
Fill in {name} Vyplnit {name}
Set the image of {name} Nastavit obrázek pole {name}
Edit script of {name} Upravit skript {name}

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

View File

@ -1804,6 +1804,8 @@
{month} {y} {month} {y}
Change {key} {key} ändern
Insert pages from {name} Seiten aus {name} einfügen
Insert pages from {name} — 1 field renamed Seiten aus {name} einfügen — 1 Feld umbenannt
Insert pages from {name} — fields renamed Seiten aus {name} einfügen — Felder umbenannt
Fill in {name} {name} ausfüllen
Set the image of {name} Bild von {name} festlegen
Edit script of {name} Skript von {name} bearbeiten
@ -1961,6 +1963,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-Formular: Seine Felder und XFA-Daten werden synchron gehalten, damit andere Viewer anzeigen, was du ausfüllst.
@plural {n} page|{n} pages {n} Seite|{n} Seiten
@plural {n} field|{n} fields {n} Feld|{n} Felder
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} Feldname kollidiert mit früheren Dateien und wird umbenannt: {list}|{n} Feldnamen kollidieren mit früheren Dateien und werden umbenannt: {list}
Switch to two-page view first to show the cover page Wechsle zuerst zur Doppelseitenansicht, um das Deckblatt anzuzeigen
Page display: {layout} Seitenanzeige: {layout}
Fit to width scrolling An Breite anpassen und scrollen

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

View File

@ -1305,6 +1305,8 @@
{month} {y} {month} {y}
Change {key} Αλλαγή {key}
Insert pages from {name} Εισαγωγή σελίδων από {name}
Insert pages from {name} — 1 field renamed Εισαγωγή σελίδων από {name} — μετονομάστηκε 1 πεδίο
Insert pages from {name} — fields renamed Εισαγωγή σελίδων από {name} — μετονομάστηκαν πεδία
Fill in {name} Συμπλήρωση του {name}
Set the image of {name} Ορισμός εικόνας του {name}
Edit script of {name} Επεξεργασία δέσμης ενεργειών του {name}
@ -2280,6 +2282,7 @@ scroll speed Fast Γρήγορη
Tools panel side Πλευρά πλαισίου εργαλείων
Add comment Προσθήκη σχολίου
Change tag properties Αλλαγή ιδιοτήτων ετικέτας
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list}
Scan Σάρωση
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί.
Choose photo… Επιλογή φωτογραφίας…

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -1883,6 +1883,8 @@ action wizard Start Iniciar
{month} {y} {month} de {y}
Change {key} Cambiar {key}
Insert pages from {name} Insertar páginas de {name}
Insert pages from {name} — 1 field renamed Insertar páginas de {name} — 1 campo renombrado
Insert pages from {name} — fields renamed Insertar páginas de {name} — campos renombrados
Fill in {name} Rellenar {name}
Set the image of {name} Definir la imagen de {name}
Edit script of {name} Editar el script de {name}
@ -2041,6 +2043,7 @@ action wizard Start Iniciar
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulario XFA: sus campos y sus datos XFA se mantienen sincronizados, para que otros visores muestren lo que rellenes.
@plural {n} page|{n} pages {n} página|{n} páginas
@plural {n} field|{n} fields {n} campo|{n} campos
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nombre de campo coincide con el de archivos anteriores y se renombrará: {list}|{n} nombres de campo coinciden con los de archivos anteriores y se renombrarán: {list}
Switch to two-page view first to show the cover page Cambia primero a la vista de dos páginas para mostrar la portada
Page display: {layout} Presentación de página: {layout}
Fit to width scrolling Desplazamiento ajustado al ancho

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

View File

@ -1799,6 +1799,8 @@
{month} {y} {month} {y}
Change {key} Modifier {key}
Insert pages from {name} Insérer des pages depuis {name}
Insert pages from {name} — 1 field renamed Insérer des pages depuis {name} — 1 champ renommé
Insert pages from {name} — fields renamed Insérer des pages depuis {name} — champs renommés
Fill in {name} Remplir {name}
Set the image of {name} Définir l'image de {name}
Edit script of {name} Modifier le script de {name}
@ -1956,6 +1958,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulaire XFA : ses champs et ses données XFA restent synchronisés, afin que les autres visionneuses affichent ce que vous remplissez.
@plural {n} page|{n} pages {n} page|{n} pages
@plural {n} field|{n} fields {n} champ|{n} champs
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nom de champ entre en collision avec les fichiers précédents et sera renommé : {list}|{n} noms de champs entrent en collision avec les fichiers précédents et seront renommés : {list}
Switch to two-page view first to show the cover page Passez d'abord à l'affichage sur deux pages pour montrer la couverture
Page display: {layout} Affichage des pages : {layout}
Fit to width scrolling Défilement ajusté à la largeur

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

View File

@ -1789,6 +1789,8 @@
{month} {y} {y}. {month}
Change {key} {key} módosítása
Insert pages from {name} Oldalak beszúrása ebből: {name}
Insert pages from {name} — 1 field renamed Oldalak beszúrása ebből: {name} — 1 mező átnevezve
Insert pages from {name} — fields renamed Oldalak beszúrása ebből: {name} — mezők átnevezve
Fill in {name} {name} kitöltése
Set the image of {name} {name} képének beállítása
Edit script of {name} {name} szkriptjének szerkesztése
@ -1946,6 +1948,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-űrlap: a mezői és az XFA-adatai együtt maradnak, így más megjelenítők azt mutatják, amit beírsz.
@plural {n} page|{n} pages {n} oldal|{n} oldal
@plural {n} field|{n} fields {n} mező|{n} mező
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} mezőnév ütközik a korábbi fájlokkal, és át lesz nevezve: {list}|{n} mezőnév ütközik a korábbi fájlokkal, és át lesz nevezve: {list}
Switch to two-page view first to show the cover page Előbb válts kétoldalas nézetre a borító megjelenítéséhez
Page display: {layout} Oldalmegjelenítés: {layout}
Fit to width scrolling Görgetés szélességhez igazítva

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

View File

@ -1874,6 +1874,8 @@
{month} {y} {month} {y}
Change {key} Modifica {key}
Insert pages from {name} Inserisci pagine da {name}
Insert pages from {name} — 1 field renamed Inserisci pagine da {name} — 1 campo rinominato
Insert pages from {name} — fields renamed Inserisci pagine da {name} — campi rinominati
Fill in {name} Compila {name}
Set the image of {name} Imposta l'immagine di {name}
Edit script of {name} Modifica script di {name}
@ -2086,6 +2088,7 @@
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{bin_added} / −{bin_deleted} risorse binarie, {first} – {last}
@plural {n} page|{n} pages {n} pagina|{n} pagine
@plural {n} field|{n} fields {n} campo|{n} campi
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nome di campo è in conflitto con i file precedenti e verrà rinominato: {list}|{n} nomi di campo sono in conflitto con i file precedenti e verranno rinominati: {list}
# Labels added on main after this catalog was written (merged 2026-10-09)
A button Un pulsante

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

View File

@ -1880,6 +1880,8 @@
{month} {y} {y}年{month}
Change {key} {key}を変更
Insert pages from {name} {name} からページを挿入
Insert pages from {name} — 1 field renamed {name} からページを挿入 — 1 個のフィールド名を変更
Insert pages from {name} — fields renamed {name} からページを挿入 — フィールド名を変更
Fill in {name} {name} に入力
Set the image of {name} {name} の画像を設定
Edit script of {name} {name} のスクリプトを編集
@ -2159,6 +2161,7 @@
−Bin −Bin
−LOC −LOC
@plural {n} field|{n} fields {n} 個のフィールド
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 個のフィールド名が前のファイルと競合するため、名前が変更されます: {list}
@plural {n} page|{n} pages {n} ページ
signature pad Type 入力
Flatten Fill & Sign when saving 保存時に入力と署名を統合する

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

View File

@ -1795,6 +1795,8 @@
{month} {y} {month} {y}
Change {key} Өзгерту: {key}
Insert pages from {name} Беттерді {name} файлынан енгізу
Insert pages from {name} — 1 field renamed {name} ішінен беттерді кірістіру — 1 өріс қайта аталды
Insert pages from {name} — fields renamed {name} ішінен беттерді кірістіру — өрістер қайта аталды
Fill in {name} {name} толтыру
Set the image of {name} {name} кескінін орнату
Edit script of {name} {name} скриптін түзету
@ -2262,6 +2264,7 @@ scroll speed Fast Жылдам
Tools panel side Құралдар панелінің жағы
Add comment Түсініктеме қосу
Change tag properties Тег қасиеттерін өзгерту
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}
Scan Сканерлеу
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды.
Choose photo… Суретті таңдау…

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

View File

@ -1861,6 +1861,8 @@
{month} {y} {month} {y}
Change {key} Zmień {key}
Insert pages from {name} Wstaw strony z {name}
Insert pages from {name} — 1 field renamed Wstaw strony z {name} — zmieniono nazwę 1 pola
Insert pages from {name} — fields renamed Wstaw strony z {name} — zmieniono nazwy pól
Fill in {name} Wypełnij {name}
Set the image of {name} Ustaw obraz pola {name}
Edit script of {name} Edytuj skrypt pola {name}
@ -2138,6 +2140,7 @@
−Bin −Bin
−LOC −LOC
@plural {n} field|{n} fields {n} pole|{n} pola|{n} pól
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Nazwa {n} pola koliduje z wcześniejszymi plikami i zostanie zmieniona: {list}|Nazwy {n} pól kolidują z wcześniejszymi plikami i zostaną zmienione: {list}|{n} nazw pól koliduje z wcześniejszymi plikami i zostanie zmienionych: {list}
@plural {n} page|{n} pages {n} strona|{n} strony|{n} stron
signature pad Type Wpisz
Flatten Fill & Sign when saving Spłaszczaj elementy Wypełnij i podpisz przy zapisie

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

View File

@ -1832,6 +1832,8 @@
Images and text (converted to PDF) Imagens e texto (convertidos para PDF)
Import {name} Importar {name}
Insert pages from {name} Inserir páginas de {name}
Insert pages from {name} — 1 field renamed Inserir páginas de {name} — 1 campo renomeado
Insert pages from {name} — fields renamed Inserir páginas de {name} — campos renomeados
Items marked Ready work today. Os itens marcados como Pronto já funcionam hoje.
JavaScript JavaScript
JavaScript, e.g. getField("total").value JavaScript, ex.: getField("total").value
@ -1942,6 +1944,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulário XFA: seus campos e seus dados XFA são mantidos sincronizados, para que outros leitores exibam o que você preencher.
@plural {n} page|{n} pages {n} página|{n} páginas
@plural {n} field|{n} fields {n} campo|{n} campos
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} nome de campo colide com arquivos anteriores e será renomeado: {list}|{n} nomes de campos colidem com arquivos anteriores e serão renomeados: {list}
Fit to width scrolling Ajustar à largura com rolagem
Fit one full page Ajustar uma página inteira
Default page display Exibição de página padrão

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

View File

@ -1870,6 +1870,8 @@
{month} {y} {month} {y}
Change {key} Изменить {key}
Insert pages from {name} Вставить страницы из {name}
Insert pages from {name} — 1 field renamed Вставить страницы из {name} — 1 поле переименовано
Insert pages from {name} — fields renamed Вставить страницы из {name} — поля переименованы
Fill in {name} Заполнить {name}
Set the image of {name} Задать изображение: {name}
Edit script of {name} Редактировать скрипт: {name}
@ -2042,6 +2044,7 @@
@plural {n} page|{n} pages {n} страница|{n} страницы|{n} страниц
@plural {n} field|{n} fields {n} поле|{n} поля|{n} полей
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Имя {n} поля конфликтует с предыдущими файлами и будет переименовано: {list}|Названия {n} полей конфликтуют с предыдущими файлами и будут переименованы: {list}|Названия {n} полей конфликтуют с предыдущими файлами и будут переименованы: {list}
Create PDF from multiple files… Создать PDF из нескольких файлов…
Optimizing… removing user data Оптимизация… удаление пользовательских данных
Optimizing… image {d} of {t} Оптимизация… изображение {d} из {t}

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

View File

@ -940,6 +940,8 @@
Insert pages from a file… Vstavi strani iz datoteke…
Insert pages from file… Vstavi strani iz datoteke…
Insert pages from {name} Vstavi strani iz: {name}
Insert pages from {name} — 1 field renamed Vstavi strani iz {name} — 1 polje preimenovano
Insert pages from {name} — fields renamed Vstavi strani iz {name} — polja preimenovana
Insert text Vstavi besedilo
Inserted Vstavljeno
Inserted text Vstavljeno besedilo
@ -2269,6 +2271,7 @@ scroll speed Fast Hitro
Tools panel side Stran podokna z orodji
Add comment Dodaj komentar
Change tag properties Spremeni lastnosti oznake
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list}
Scan Skeniraj
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir.
Choose photo… Izberi fotografijo…

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

View File

@ -1789,6 +1789,8 @@ action wizard Start ప్రారంభించు
{month} {y} {month} {y}
Change {key} {key} మార్చు
Insert pages from {name} {name} నుండి పేజీలను చొప్పించు
Insert pages from {name} — 1 field renamed {name} నుండి పేజీలను చొప్పించు — 1 ఫీల్డ్ పేరు మార్చబడింది
Insert pages from {name} — fields renamed {name} నుండి పేజీలను చొప్పించు — ఫీల్డ్‌ల పేర్లు మార్చబడ్డాయి
Fill in {name} {name}ను నింపు
Set the image of {name} {name} చిత్రాన్ని సెట్ చేయి
Edit script of {name} {name} స్క్రిప్ట్‌ను సవరించు
@ -1944,6 +1946,7 @@ action wizard Start ప్రారంభించు
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA ఫారం: దీని ఫీల్డ్‌లు, XFA డేటా ఎప్పుడూ ఒకేలా ఉంచబడతాయి, కాబట్టి మీరు నింపినది ఇతర వ్యూయర్‌లలో కూడా కనిపిస్తుంది.
@plural {n} page|{n} pages {n} పేజీ|{n} పేజీలు
@plural {n} field|{n} fields {n} ఫీల్డ్|{n} ఫీల్డ్‌లు
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ఫీల్డ్ పేరు మునుపటి ఫైళ్లతో సరిపోలుతుంది, పేరు మార్చబడుతుంది: {list}|{n} ఫీల్డ్ పేర్లు మునుపటి ఫైళ్లతో సరిపోలుతున్నాయి, పేర్లు మార్చబడతాయి: {list}
Switch to two-page view first to show the cover page కవర్ పేజీని చూపించడానికి ముందుగా రెండు పేజీల వీక్షణకు మారండి
Page display: {layout} పేజీ ప్రదర్శన: {layout}
Fit to width scrolling వెడల్పుకు సరిపోయేలా స్క్రోలింగ్

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

View File

@ -1766,6 +1766,8 @@
{month} {y} {month} {y}
Change {key} {key} değiştir
Insert pages from {name} {name} belgesinden sayfalar ekle
Insert pages from {name} — 1 field renamed {name} dosyasından sayfa ekle — 1 alan yeniden adlandırıldı
Insert pages from {name} — fields renamed {name} dosyasından sayfa ekle — alanlar yeniden adlandırıldı
Fill in {name} {name} doldur
Set the image of {name} {name} resmini ayarla
Edit script of {name} {name} komut dosyasını düzenle
@ -2260,6 +2262,7 @@ scroll speed Fast Hızlı
Tools panel side Araçlar panelinin yeri
Add comment Yorum ekle
Change tag properties Etiket özelliklerini değiştir
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list}
Scan Tara
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır.
Choose photo… Fotoğraf seç…

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -1867,6 +1867,8 @@
{month} {y} {month} {y}
Change {key} Змінити: {key}
Insert pages from {name} Вставити сторінки з {name}
Insert pages from {name} — 1 field renamed Вставити сторінки з {name} — 1 поле перейменовано
Insert pages from {name} — fields renamed Вставити сторінки з {name} — поля перейменовано
Fill in {name} Заповнити {name}
Set the image of {name} Задати зображення для {name}
Edit script of {name} Редагувати сценарій {name}
@ -2033,6 +2035,7 @@
Couldn't import the signature image: {e} Не вдалося імпортувати зображення підпису: {e}
@plural {n} page|{n} pages {n} сторінка|{n} сторінки|{n} сторінок
@plural {n} field|{n} fields {n} поле|{n} поля|{n} полів
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} Назва {n} поля збігається з попередніми файлами та буде перейменована: {list}|Назви {n} полів збігаються з попередніми файлами та будуть перейменовані: {list}|Назви {n} полів збігаються з попередніми файлами та будуть перейменовані: {list}
Create PDF from multiple files… Створити PDF із кількох файлів…
Optimizing… removing user data Оптимізація… видалення даних користувача
Optimizing… image {d} of {t} Оптимізація… зображення {d} із {t}

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

View File

@ -1970,6 +1970,8 @@
Images and text (converted to PDF) 图像和文本(转换为 PDF)
Import {name} 导入 {name}
Insert pages from {name} 从 {name} 插入页面
Insert pages from {name} — 1 field renamed 从 {name} 插入页面 — 已重命名 1 个字段
Insert pages from {name} — fields renamed 从 {name} 插入页面 — 已重命名字段
Items marked Ready work today. 标记为就绪的项目现已可用。
JavaScript JavaScript
JavaScript, e.g. getField("total").value JavaScript,例如 getField("total").value
@ -2097,6 +2099,7 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA 表单:字段与 XFA 数据保持同步,以便其他阅读器显示您填写的内容。
@plural {n} page|{n} pages {n} 页
@plural {n} field|{n} fields {n} 个字段
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 个字段名与之前的文件冲突,将被重命名: {list}
Created a PDF from {n} files 已从 {n} 个文件创建 PDF
PDF, images and text PDF、图像和文本
Insert a file before page {label} 在第 {label} 页之前插入文件

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

View File

@ -1868,6 +1868,8 @@
{month} {y} {y} 年 {month}
Change {key} 變更{key}
Insert pages from {name} 從 {name} 插入頁面
Insert pages from {name} — 1 field renamed 從 {name} 插入頁面 — 已重新命名 1 個欄位
Insert pages from {name} — fields renamed 從 {name} 插入頁面 — 已重新命名欄位
Fill in {name} 填寫 {name}
Set the image of {name} 設定 {name} 的圖片
Edit script of {name} 編輯 {name} 的指令碼
@ -2071,6 +2073,7 @@
# Multi-object selection and movement (#844)
@plural {n} object selected|{n} objects selected 已選取 {n} 個物件
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} 個欄位名稱與先前的檔案衝突,將被重新命名: {list}
Drag the selection to move it. Esc clears the selection. 拖曳選取範圍即可移動。按 Esc 清除選取。
Shift-click to select several objects. Drag empty space to select an area. 按住 Shift 點選可選取多個物件。拖曳空白區域可框選。
Select at most 1000 objects at a time. 一次最多選取 1000 個物件。

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

View File

@ -345,6 +345,8 @@ pub struct DuplicateDraft {
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
#[derive(Clone, Debug)]
pub struct PageClip {
/// The document the pages were copied from: pasting back into it keeps field names.
pub from: DocId,
pub name: String,
pub bytes: std::sync::Arc<Vec<u8>>,
pub pages: Vec<usize>,
@ -588,6 +590,8 @@ pub struct PdfCraftApp {
pub space_audit: Vec<pdfcraft_engine::optimize::SpaceUse>,
/// Combine files: the files staged so far.
pub combine_draft: Vec<combine_ui::CombineFile>,
/// The combine list's field-name conflict preview (computed once per list, never saved).
pub combine_conflicts: combine_ui::ConflictPreview,
/// The Combine files tab: whether it is open, shown, its selection and undo history.
pub combine_tab: combine_ui::CombineTab,
/// The Combine files table's column order and widths (kept in the settings).
@ -860,6 +864,7 @@ impl PdfCraftApp {
cert_viewer: None,
space_audit: Vec::new(),
combine_draft: Vec::new(),
combine_conflicts: Default::default(),
combine_tab: Default::default(),
combine_columns: Default::default(),
combine_view: Default::default(),

View File

@ -779,6 +779,85 @@ fn edit_menu_names_the_step_to_undo() {
// ── Combine / insert from file / extract / split ──────────────────────────────────────────────
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec<u8> {
form_pdf_pages(name, 1)
}
/// A PDF of `pages` pages whose form holds one text field named `name` (on the first page).
fn form_pdf_pages(name: &str, pages: usize) -> Vec<u8> {
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 3 + i)).collect();
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 400] >>", kids.join(" ")),
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(),
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"),
];
for _ in 1..pages {
objs.push("<< /Type /Page /Parent 2 0 R >>".to_string()); // objects after the field's number
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// organize.field-name-conflicts: the Combine tab warns, before anything is combined, that a
/// file's form fields collide with earlier files' and will be renamed, naming the new names.
#[test]
fn combine_list_warns_that_colliding_field_names_will_be_renamed() {
let mut h = harness(1, |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), form_pdf("Text1")), ("two.pdf".into(), form_pdf("Text1"))]);
});
h.run_steps(3);
h.get_by_label_contains("will be renamed");
h.get_by_label_contains("Text1 → Text1-1");
}
/// A file split into parts (its pages moved among other files') is combined as one file, once —
/// so the preview must not warn that its field names collide with their own.
#[test]
fn combine_list_does_not_warn_that_a_split_file_collides_with_itself() {
let mut h = harness(1, |app| {
app.set_option("combine-view", "list").unwrap();
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), form_pdf_pages("Text1", 2))]);
});
h.run_steps(3);
// one.pdf in two parts — pages 1 and 2, sharing a group, as moving its pages apart leaves it.
let mut part = h.state().combine_draft[0].clone();
part.id += 1000;
h.state_mut().combine_draft[0].range = "1".into();
part.range = "2".into();
h.state_mut().combine_draft.push(part);
h.run_steps(3);
assert!(h.query_by_label_contains("will be renamed").is_none(), "one file, however many parts, cannot collide with itself");
}
/// Copying a page with a field and pasting it back into the same document is a duplicate: the
/// copy keeps the field's name rather than becoming Text1-1.
#[test]
fn pasting_pages_into_their_own_document_keeps_field_names() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, form_pdf("Text1")).unwrap();
app.views[0].select_pages(&[0]);
assert!(app.execute("page.copy"));
assert!(app.execute("page.paste"));
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.pages.len(), 2, "pasted");
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::<Vec<_>>());
}
fn texts_of(app: &PdfCraftApp, tab: usize) -> Vec<String> {
let doc = app.session.get(app.views[tab].id).unwrap();
let mut r = PageRenderer::new(doc.bytes.clone(), Default::default());

View File

@ -3067,11 +3067,11 @@ title = "Rename or merge colliding field names when combining"
area = "D"
tier = "P1"
milestone = "M4"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["page.insert", "page.combine"]
tools = ["doc_combine"]
evidence = ["crates/organize/src/tests.rs::import_renames_a_colliding_field_name", "crates/organize/src/tests.rs::import_renames_to_the_first_free_variant", "crates/organize/src/tests.rs::import_renames_away_from_an_orphaned_widgets_name", "crates/organize/src/tests.rs::import_renames_a_nested_field_whose_full_name_collides", "crates/organize/src/tests.rs::import_renames_the_colliding_child_not_the_whole_tree", "crates/organize/src/tests.rs::import_renames_a_field_colliding_with_another_incoming_field", "crates/organize/src/tests.rs::import_can_merge_into_same_named_fields_instead", "crates/organize/src/tests.rs::a_renamed_field_tree_survives_a_save_and_reopen", "crates/organize/src/tests.rs::a_repeated_page_joins_the_renamed_field", "crates/organize/src/tests.rs::combine_renames_colliding_field_names_and_reports_them", "crates/organize/src/tests.rs::combine_grouped_renames_colliding_field_names", "crates/organize/src/tests.rs::combine_field_conflicts_previews_each_file_in_order", "crates/engine/src/tests.rs::insert_from_file_renames_colliding_fields_and_notes_it", "crates/engine/src/tests.rs::combine_reports_renamed_fields_and_can_merge", "crates/automation/tests/automation.rs::combine_renames_colliding_field_names", "crates/ui-egui/tests/editing.rs::combine_list_warns_that_colliding_field_names_will_be_renamed"]
notes = "Collisions are found by fully-qualified name (parent.T.child), counting names that hang only off page widgets even when /AcroForm never lists them, and also between two incoming fields. The colliding field's own /T takes the first free variant (Text1-1, Text1-2, …), children keep their place, and nothing is silent: inserting into an open document notes each rename in the repair log (warning that scripts referring to the old names were not rewritten) and the undo step's label counts them; doc_combine lists renamed_fields in its result; the Combine tab's Warnings column previews the renames per file before combining. field_conflicts \"merge\" on doc_combine (and the engine policy) keeps the older one-field behaviour. Still missing: the Combine tab's preview considers whole files, not a narrowed page range; the UI offers no merge choice (it always renames); Create ▸ Multiple files renames without reporting; scripts and XFA bindings that reference old names are never rewritten; Acrobat's behaviour not observed."
[[feature]]
id = "organize.struct-tree-merge"