collective

Browse / github.com/storytold/pdfcraft / patch

M4: interleave the pages of two or more files, like pdftk shuffle (#4922)

Implement PDF page shuffle/interleaving feature Add a feature to PDF Craft that can merge two PDFs by **interleaving their pages**, similar to `pdftk`'s shuffle functionality. Given two input PDFs: - PDF A provides pages **1, 3, 5, 7, ...** - PDF B provides pages **2, 4, 6, 8, ...** The result should be a single PDF with the pages alternating between the two input files. ### Use case This is particularly useful for scanning books or documents with a double-sided scanner that can only scan one side at a time. For example, I can: 1. Scan all the **front sides** of the pages into `front.pdf`. 2. Flip the entire stack of paper over. 3. S

Status
open
Score
0.00
Adopters
0 other operators
Author
AdrianTM (imported from pull request #4980, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+525 −2 in 14 files
Review
pending
Shared
2026-10-28
Id
c9dde8ee613d3bdb51c6477b

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/automation/src/assemble.rs crates/automation/src/dispatch.rs crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/engine/src/session/files.rs crates/engine/src/tests.rs crates/organize/src/lib.rs crates/organize/src/tests.rs crates/ui-egui/src/workflows/combine_ui/session.rs crates/ui-egui/src/workflows/combine_ui/view.rs crates/ui-egui/tests/it/combine_grid.rs docs/mcp.md parity/acrobat-features.toml

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -745,3 +745,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.  - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M4, #4922):** Interleave (shuffle) the pages of two or more files, as `pdftk A B shuffle`: an Interleave pages button in the Combine files tab and `doc_shuffle` (`Session::shuffle`, `pdfcraft_organize::reorder_pages`). Page 1 of each file, then page 2 of each; a shorter file drops out and the longer ones carry on. The files are combined whole and the pages moved once, so it is linear (two 10,000-page files in half a second).diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs--- a/crates/automation/src/assemble.rs+++ b/crates/automation/src/assemble.rs@@ -266,6 +266,51 @@ impl Automation {         Ok(out)     } +    /// Interleave the pages of two or more files (`pdftk A B shuffle`, #4922).+    pub(crate) fn doc_shuffle(&mut self, a: &Args) -> Result<Value> {+        let paths = a.strs("paths")?;+        if paths.len() < 2 {+            return Err(ToolError::InvalidArgs("a shuffle needs at least two files".into()));+        }+        let passwords: Vec<Option<String>> = match a.get("passwords") {+            None | Some(Value::Null) => vec![None; paths.len()],+            Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => {+                v.iter().map(|x| x.as_str().map(str::to_owned)).collect()+            }+            Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())),+        };+        let ranges: Vec<Option<String>> = match a.get("pages") {+            None | Some(Value::Null) => vec![None; paths.len()],+            Some(Value::Array(v)) if v.len() == paths.len() => v+                .iter()+                .enumerate()+                .map(|(index, value)| match value {+                    Value::Null => Ok(None),+                    Value::String(range) => Ok(Some(range.clone())),+                    _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))),+                })+                .collect::<Result<_>>()?,+            Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),+        };+        // Each file is read and made into a PDF in turn (images and text files too, as Combine does).+        let mut files: Vec<CombineSource> = Vec::with_capacity(paths.len());+        for p in paths {+            let path = self.resolve(p, false)?;+            let bytes = Arc::new(read_input(&path)?);+            let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+            let pdf = self.session.as_pdf(&file_name, &bytes).map_err(failed)?;+            let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+            files.push((title, pdf, ranges.get(files.len()).cloned().flatten()));+        }+        let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();+        let shuffled = self.session.shuffle(&files, &passwords).map_err(failed)?;+        let mut result = self.deliver(a, "Shuffled", shuffled.bytes)?;+        if !shuffled.renamed_fields.is_empty() {+            result["renamed_fields"] = json!(shuffled.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::<Vec<_>>());+        }+        Ok(result)+    }+     pub(crate) fn doc_combine(&mut self, a: &Args) -> Result<Value> {         let paths = a.strs("paths")?;         if paths.len() < 2 {diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs--- a/crates/automation/src/dispatch.rs+++ b/crates/automation/src/dispatch.rs@@ -126,6 +126,7 @@ impl Automation {             "page_insert_file" => self.insert_file(&a)?,             "page_extract" => self.page_extract(&a)?,             "doc_combine" => self.doc_combine(&a)?,+            "doc_shuffle" => self.doc_shuffle(&a)?,             "doc_create_multiple" => self.doc_create_multiple(&a)?,             "doc_split" => self.doc_split(&a)?,             "edit_undo" => {diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -265,6 +265,21 @@ pub fn tools() -> Vec<ToolDef> {                 }),                 &["paths"],             )),+        t(+            "doc_shuffle",+            "Shuffle (interleave) files",+            "Interleave the pages of two or more files, as pdftk's shuffle does: page 1 of each file in turn, then page 2 of each, and so on. A file that runs out drops out and the longer ones carry on, so a front.pdf of five pages and a back.pdf of five give front 1, back 1, front 2, back 2 ...; for a double-sided scan made in two passes. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files are made into PDFs first. passwords, in step with paths, opens encrypted files (null for none; never echoed back). pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null for all); the shuffle takes those pages in that order. Each file keeps one bookmark at its first page. When two files' forms name the same field the later one is renamed, as in doc_combine, and the result lists renamed_fields as [[old, new], …]. At most 100000 pages in all. The result is not encrypted.",+        )+        .with(schema(+            json!({+                "paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 },+                "pages": { "type": "array", "items": { "type": ["string", "null"] } },+                "passwords": { "type": "array", "items": { "type": ["string", "null"] } },+                "out": save_out.clone(),+                "open": open.clone(),+            }),+            &["paths"],+        )),         t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")             .cmd("create.multiple")             .with(schema(diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs--- a/crates/automation/tests/it/automation.rs+++ b/crates/automation/tests/it/automation.rs@@ -1122,6 +1122,72 @@ fn combine_takes_images_and_text_files() {     } } +/// #4922: doc_shuffle interleaves the pages of its files, takes images and text files, writes+/// to `out` inside the root, and refuses what it can't shuffle.+#[test]+fn shuffle_interleaves_files_through_the_tool() {+    let dir = workdir("shuffle");+    let mut png = Vec::new();+    {+        let mut encoder = png::Encoder::new(&mut png, 4, 2);+        encoder.set_color(png::ColorType::Rgb);+        encoder.set_depth(png::BitDepth::Eight);+        encoder.write_header().unwrap().write_image_data(&[90; 24]).unwrap();+    }+    std::fs::write(dir.join("scan.png"), png).unwrap();+    std::fs::write(dir.join("notes.txt"), "Some notes").unwrap();+    let mut a = auto(&dir);++    // a.pdf is "Page 1".."Page 3"; the text file is one page; the rest of the longer file follows.+    let two = ok(&mut a, "doc_shuffle", json!({ "paths": ["a.pdf", "notes.txt"], "open": true }));+    let doc = two["document"]["doc"].as_u64().unwrap();+    assert_eq!(two["document"]["pages"], 4);+    assert_eq!(page_text(&mut a, doc), ["Page 1", "Some notes", "Page 2", "Page 3"]);+    // Three files (an image page has no text), and the file's own bookmark at its first page.+    let three = ok(&mut a, "doc_shuffle", json!({ "paths": ["a.pdf", "scan.png", "notes.txt"], "open": true }));+    let doc = three["document"]["doc"].as_u64().unwrap();+    assert_eq!(page_text(&mut a, doc), ["Page 1", "", "Some notes", "Page 2", "Page 3"]);+    let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone();+    let titles: Vec<&str> = marks.as_array().unwrap().iter().map(|b| b["title"].as_str().unwrap()).collect();+    assert_eq!(titles, ["a", "scan", "notes"]);+    // Colliding form field names are renamed and reported, as doc_combine does.+    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 forms = ok(&mut a, "doc_shuffle", json!({ "paths": ["form-a.pdf", "form-b.pdf"], "open": true }));+    assert_eq!(forms["renamed_fields"], json!([["Text1", "Text1-1"]]), "{forms}");+    assert!(two.get("renamed_fields").is_none(), "no fields, nothing renamed: {two}");+    // pages chooses each file's pages, in the range's order.+    let chosen = ok(&mut a, "doc_shuffle", json!({ "paths": ["a.pdf", "a.pdf"], "pages": ["3, 1", "2"], "open": true }));+    let doc = chosen["document"]["doc"].as_u64().unwrap();+    assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]);+    for bad in [json!({ "pages": ["1"] }), json!({ "pages": [1, 2] }), json!({ "pages": ["9", null] })] {+        let mut args = json!({ "paths": ["a.pdf", "b.pdf"] });+        args["pages"] = bad["pages"].clone();+        assert!(a.call("doc_shuffle", &args).is_err(), "{args}");+    }+    // Written to a file inside the root, without opening it.+    let written = ok(&mut a, "doc_shuffle", json!({ "paths": ["a.pdf", "b.pdf"], "out": "shuffled.pdf", "open": false }));+    assert!(written.get("document").is_none());+    assert!(dir.join("shuffled.pdf").is_file());+    let reopened = ok(&mut a, "doc_open", json!({ "path": "shuffled.pdf" }))["doc"].as_u64().unwrap();+    assert_eq!(page_text(&mut a, reopened).len(), 5);++    // Refused: one file, a missing file, a file it can't make a PDF of, a wrong passwords list, and+    // an `out` outside the root. Nothing is written for any of them.+    for args in [+        json!({ "paths": ["a.pdf"] }),+        json!({ "paths": ["a.pdf", "missing.pdf"] }),+        json!({ "paths": ["a.pdf", "b.pdf"], "passwords": ["only one"] }),+        json!({ "paths": ["a.pdf", "b.pdf"], "out": "../escaped.pdf" }),+    ] {+        assert!(a.call("doc_shuffle", &args).is_err(), "{args}");+    }+    assert!(!dir.parent().unwrap().join("escaped.pdf").exists());+    std::fs::write(dir.join("broken.png"), b"\x89PNG\r\n\x1a\nbroken").unwrap();+    let refused = a.call("doc_shuffle", &json!({ "paths": ["a.pdf", "broken.png"] }));+    assert!(matches!(&refused, Err(ToolError::Failed(e)) if e.contains("broken.png")), "{refused:?}");+}+ #[test] fn combine_extract_and_split() {     let dir = workdir("organize");diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs--- a/crates/engine/src/session/files.rs+++ b/crates/engine/src/session/files.rs@@ -268,6 +268,65 @@ impl Session {         Ok(Combined { bytes, renamed_fields: out.renamed_fields })     } +    /// Most pages a shuffle writes (the sum over its files), so a hostile page count can't ask+    /// for an unbounded list of parts.+    pub const MAX_SHUFFLE_PAGES: usize = 100_000;++    /// Interleave the pages of two or more files, as `pdftk A.pdf B.pdf shuffle` does (#4922):+    /// page 1 of each file in turn, then page 2 of each, and so on. A file that runs out drops+    /// out and the longer ones carry on, so `A1 A2 A3` and `B1` give `A1 B1 A2 A3`. Each file+    /// is its pages in the order of its range (`None` or empty: all of them, in order), keeps one+    /// bookmark, at its first page, and is opened with its own password (by position; missing or+    /// `None`: none). Colliding form field names are renamed, as in Combine, and reported. The+    /// result is not encrypted.+    pub fn shuffle(&self, files: &[CombineSource], passwords: &[Option<&str>]) -> Result<Combined, EditError> {+        if files.len() < 2 {+            return Err(EditError::Invalid("a shuffle needs at least two files".into()));+        }+        // How many pages each file contributes, by its range.+        let mut counts = Vec::with_capacity(files.len());+        for (i, (name, bytes, range)) in files.iter().enumerate() {+            let doc = open_source_with(name, bytes, passwords.get(i).copied().flatten())?;+            let n = pdfcraft_organize::page_count(&doc)?;+            counts.push(match range.as_deref().map(str::trim).filter(|r| !r.is_empty()) {+                Some(r) => pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)+                    .map_err(|e| EditError::Print(format!("{name}: {e}")))?+                    .len(),+                None => n,+            });+        }+        let total = counts.iter().try_fold(0usize, |sum, n| sum.checked_add(*n)).filter(|n| *n <= Self::MAX_SHUFFLE_PAGES);+        let Some(total) = total else {+            return Err(EditError::Invalid(format!("a shuffle writes at most {} pages", Self::MAX_SHUFFLE_PAGES)));+        };+        // The files' pages one file after the other, then moved once into their shuffled places.+        let joined = self.combine_unlocked_reporting(files, passwords, pdfcraft_organize::FieldConflicts::Rename)?;+        // Where each file's first page is in the joined document.+        let starts: Vec<usize> = counts+            .iter()+            .scan(0usize, |next, count| {+                let start = *next;+                *next = next.saturating_add(*count);+                Some(start)+            })+            .collect();+        // Each round takes one page from every file that has one left, in file order; a file that+        // has run out leaves the list, so the work follows the pages, not the files times the longest.+        let mut active: Vec<usize> = (0..counts.len()).filter(|f| counts.get(*f).is_some_and(|c| *c > 0)).collect();+        let mut order = Vec::with_capacity(total);+        let mut round = 0usize;+        while !active.is_empty() {+            for &file in &active {+                order.push(starts.get(file).copied().unwrap_or(0).saturating_add(round));+            }+            round = round.saturating_add(1);+            active.retain(|f| counts.get(*f).is_some_and(|c| *c > round));+        }+        let mut doc = open_source("Shuffled", &joined.bytes)?;+        pdfcraft_organize::reorder_pages(&mut doc, &order)?;+        Ok(Combined { bytes: self.write_new(&doc)?, renamed_fields: joined.renamed_fields })+    }+     /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one     /// file split into several runs placed apart: each file is opened and copied once, so it keeps     /// one bookmark, its links between its own pages, its fields and its attachments whole (seediff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -3476,6 +3476,128 @@ fn added_text_and_images_render_and_stay_editable() {     assert_eq!(s.get(id).unwrap().added().len(), 2); } +/// `Session::shuffle` over whole files, giving the bytes.+fn shuffled(s: &Session, files: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {+    let sources: Vec<CombineSource> = files.iter().map(|(n, b)| (n.clone(), b.clone(), None)).collect();+    s.shuffle(&sources, &[]).map(|c| c.bytes)+}++/// Shuffle (#4922): the pages of the files alternate, the longer files carry on when a shorter+/// one runs out, and a file with one page, one file or none is handled.+#[test]+fn shuffle_interleaves_the_pages_of_files() {+    let mut s = Session::new();+    // Page `p` of file `f` is `f * 100 + p` points wide, so the result shows where each page came+    // from, and in what order within its file.+    let file = |s: &Session, f: u32, pages: u32| -> Arc<Vec<u8>> {+        let one_page_each: Vec<(String, Arc<Vec<u8>>)> =+            (1..=pages).map(|p| (format!("p{p}"), s.create_blank(f64::from(f * 100 + p), 100.0, 1).unwrap())).collect();+        s.combine(&one_page_each).unwrap()+    };+    let widths = |s: &mut Session, bytes: Arc<Vec<u8>>| -> Vec<u32> {+        let id = s.open_new("shuffled.pdf", bytes).unwrap();+        s.get(id).unwrap().info.pages.iter().map(|p| p.width as u32).collect()+    };+    let name = |n: &str| n.to_string();+    let (a, b, c) = (file(&s, 1, 5), file(&s, 2, 5), file(&s, 3, 1));+    let short = file(&s, 2, 3);+    assert_eq!(widths(&mut s, a.clone()), [101, 102, 103, 104, 105], "the fixture is in page order");++    // The issue's example: fronts and backs.+    let both = shuffled(&s, &[(name("front"), a.clone()), (name("back"), b)]).unwrap();+    assert_eq!(widths(&mut s, both), [101, 201, 102, 202, 103, 203, 104, 204, 105, 205]);++    // Different lengths: the rest of the longer file follows, in its own order.+    let uneven = shuffled(&s, &[(name("a"), a.clone()), (name("b"), short.clone())]).unwrap();+    assert_eq!(widths(&mut s, uneven), [101, 201, 102, 202, 103, 203, 104, 105]);+    let reversed = shuffled(&s, &[(name("b"), short.clone()), (name("a"), a.clone())]).unwrap();+    assert_eq!(widths(&mut s, reversed), [201, 101, 202, 102, 203, 103, 104, 105]);++    // Three files, one of one page.+    let three = shuffled(&s, &[(name("a"), a.clone()), (name("b"), short), (name("c"), c)]).unwrap();+    assert_eq!(widths(&mut s, three), [101, 201, 301, 102, 202, 103, 203, 104, 105]);++    // The same file twice interleaves with itself.+    let twice = shuffled(&s, &[(name("a"), a.clone()), (name("a"), a.clone())]).unwrap();+    assert_eq!(widths(&mut s, twice), [101, 101, 102, 102, 103, 103, 104, 104, 105, 105]);++    // Fewer than two files is refused, and so is a file that is not a PDF.+    assert!(matches!(shuffled(&s, &[]), Err(EditError::Invalid(_))));+    assert!(matches!(shuffled(&s, &[(name("a"), a.clone())]), Err(EditError::Invalid(_))));+    assert!(shuffled(&s, &[(name("a"), a), (name("junk"), Arc::new(b"not a pdf".to_vec()))]).is_err());+}++/// Shuffle takes each file's pages by its range, in the range's order, so what was chosen in the+/// Combine tab is what is interleaved; a range that is not there is refused.+#[test]+fn shuffle_takes_the_pages_a_range_chooses() {+    let mut s = Session::new();+    let file = |s: &Session, f: u32, pages: u32| -> Arc<Vec<u8>> {+        let one_page_each: Vec<(String, Arc<Vec<u8>>)> =+            (1..=pages).map(|p| (format!("p{p}"), s.create_blank(f64::from(f * 100 + p), 100.0, 1).unwrap())).collect();+        s.combine(&one_page_each).unwrap()+    };+    let (a, b) = (file(&s, 1, 5), file(&s, 2, 5));+    let range = |r: &str| Some(r.to_string());+    let pick = |s: &Session, ra: Option<String>, rb: Option<String>| s.shuffle(&[("a".into(), a.clone(), ra), ("b".into(), b.clone(), rb)], &[]);+    let widths = |s: &mut Session, bytes: Arc<Vec<u8>>| -> Vec<u32> {+        let id = s.open_new("shuffled.pdf", bytes).unwrap();+        s.get(id).unwrap().info.pages.iter().map(|p| p.width as u32).collect()+    };+    // Pages 2-3 of one, pages 5 then 1 of the other (the range's order).+    let out = pick(&s, range("2-3"), range("5, 1")).unwrap().bytes;+    assert_eq!(widths(&mut s, out), [102, 205, 103, 201]);+    // A range on one file only, and the file's other pages follow when it is the longer.+    let out = pick(&s, None, range("4")).unwrap().bytes;+    assert_eq!(widths(&mut s, out), [101, 204, 102, 103, 104, 105]);+    // A descending range lists its pages backwards: a back side scanned last page first.+    let out = pick(&s, None, range("5-1")).unwrap().bytes;+    assert_eq!(widths(&mut s, out), [101, 205, 102, 204, 103, 203, 104, 202, 105, 201]);+    // An empty range is all the pages.+    let out = pick(&s, range(" "), range("1-2")).unwrap().bytes;+    assert_eq!(widths(&mut s, out), [101, 201, 102, 202, 103, 104, 105]);+    // A page that isn't there is refused, naming the file.+    let err = pick(&s, range("9"), None).unwrap_err().to_string();+    assert!(err.contains('a'), "{err}");+    assert!(pick(&s, range("junk"), None).is_err());+}++/// One long file among very many one-page files: the work follows the pages written, not the+/// files times the longest file, so it finishes at once and gets the order right.+#[test]+fn shuffle_with_one_long_file_and_very_many_short_ones_is_in_order_and_quick() {+    let mut s = Session::new();+    let mut files: Vec<(String, Arc<Vec<u8>>)> = vec![("long".into(), s.create_blank(10.0, 10.0, 5_000).unwrap())];+    // Each short file is a page of its own width, 11, 12, ... points.+    for i in 0..400u32 {+        files.push((format!("s{i}"), s.create_blank(f64::from(11 + i), 10.0, 1).unwrap()));+    }+    let started = std::time::Instant::now();+    let out = shuffled(&s, &files).unwrap();+    assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());+    let id = s.open_new("shuffled.pdf", out).unwrap();+    let widths: Vec<u32> = s.get(id).unwrap().info.pages.iter().map(|p| p.width as u32).collect();+    assert_eq!(widths.len(), 5_400);+    // Round one: the long file's first page, then each short file's page, in file order.+    assert_eq!(&widths[..4], [10, 11, 12, 13]);+    assert_eq!(widths.get(400).copied(), Some(410));+    // Then only the long file is left.+    assert!(widths[401..].iter().all(|w| *w == 10));+}++/// The pages a shuffle writes are capped, whatever the files say.+#[test]+fn shuffle_refuses_more_pages_than_its_cap() {+    let s = Session::new();+    // Ten thousand pages is the most a blank document has, so eleven of them are over the cap.+    let big = s.create_blank(10.0, 10.0, 10_000).unwrap();+    let files: Vec<(String, Arc<Vec<u8>>)> = (0..11).map(|i| (format!("f{i}"), big.clone())).collect();+    let err = shuffled(&s, &files).unwrap_err();+    assert!(matches!(err, EditError::Invalid(_)), "{err:?}");+    // Ten are exactly at it and are accepted.+    assert!(shuffled(&s, &files[..10]).is_ok());+}+ /// Edit ▸ Copy and Paste of added content (#2636): a text box comes back with all its style, an /// image with its picture and transforms, in the same document and in another. #[test]diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs--- a/crates/organize/src/lib.rs+++ b/crates/organize/src/lib.rs@@ -527,6 +527,29 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<()     rebuild(doc, &rest) } +/// Put the pages in a new order: `order[k]` is the current index of the page that goes at+/// position `k`. It must list every page exactly once; anything else is refused before the+/// document changes. One pass over the page tree, however many pages move.+pub fn reorder_pages(doc: &mut Document, order: &[usize]) -> Result<(), OrganizeError> {+    let all = walk(doc)?;+    if order.len() != all.len() {+        return Err(OrganizeError::Invalid(format!("the new order lists {} pages, the document has {}", order.len(), all.len())));+    }+    check(order, all.len())?;+    let mut seen = vec![false; all.len()];+    for &i in order {+        // `check` has bounded every index, so `get_mut` always finds its slot.+        if let Some(slot) = seen.get_mut(i) {+            if *slot {+                return Err(OrganizeError::Invalid(format!("page {} is listed twice in the new order", i + 1)));+            }+            *slot = true;+        }+    }+    let reordered: Vec<(ObjRef, Dict)> = order.iter().filter_map(|i| all.get(*i).cloned()).collect();+    rebuild(doc, &reordered)+}+ /// Reverse the order of the pages at `indices` among the positions they occupy; every other /// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs--- a/crates/organize/src/tests.rs+++ b/crates/organize/src/tests.rs@@ -404,6 +404,31 @@ fn move_pages_reorders() {     assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test]+fn reorder_pages_puts_every_page_where_the_order_says() {+    let mut doc = open(fixture());+    reorder_pages(&mut doc, &[2, 0, 1]).unwrap();+    assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);+    // The order is relative to what the document has now.+    reorder_pages(&mut doc, &[1, 2, 0]).unwrap();+    assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);+    reorder_pages(&mut doc, &[2, 1, 0]).unwrap();+    let doc = save_and_reopen(&doc);+    assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);+}++#[test]+fn reorder_pages_refuses_anything_but_a_permutation_and_changes_nothing() {+    let mut doc = open(fixture());+    // Too few, too many, a page twice, a page that is not there (also a huge index).+    for order in [&[0usize, 1][..], &[0, 1, 2, 0], &[0, 0, 1], &[0, 1, 3], &[0, 1, usize::MAX], &[]] {+        assert!(reorder_pages(&mut doc, order).is_err(), "{order:?}");+    }+    assert_eq!(reorder_pages(&mut doc, &[0, 1, 7]), Err(OrganizeError::NoSuchPage(7)));+    assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);+    assert!(!doc.is_modified(), "refused orders leave the document untouched");+}+ #[test] fn reverse_pages_reorders() {     let mut doc = open(fixture());diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs--- a/crates/ui-egui/src/workflows/combine_ui/session.rs+++ b/crates/ui-egui/src/workflows/combine_ui/session.rs@@ -423,6 +423,55 @@ impl PdfCraftApp {         self.stage_combine_with(incoming);     } +    /// Interleave the pages of the listed files (page 1 of each, then page 2 of each, and so on;+    /// #4922) into a new, unsaved document; the tab makes way for it. A file is the pages the+    /// list gives it: its Pages field, and all its parts if it is split, in the order shown.+    pub fn interleave_staged(&mut self) {+        // One entry per file, in the order each first appears; a file split into parts is the+        // parts' ranges one after the other, and a part with no Pages is all of its file's pages+        // at that place (as the grid shows it). A file of one part with no Pages is simply whole.+        let mut files: Vec<(u64, &CombineFile, Vec<String>, bool)> = Vec::new();+        for f in self.combine_draft.iter().filter(|f| !f.unreadable()) {+            let at = match files.iter().position(|(group, ..)| *group == f.group) {+                Some(at) => at,+                None => {+                    files.push((f.group, f, Vec::new(), false));+                    files.len() - 1+                }+            };+            if let Some((_, _, ranges, restricted)) = files.get_mut(at) {+                match f.range.trim() {+                    "" if f.pages > 0 => ranges.push(format!("1-{}", f.pages)),+                    "" => {}+                    range => {+                        *restricted = true;+                        ranges.push(range.to_string());+                    }+                }+            }+        }+        if files.len() < 2 {+            return;+        }+        let sources: Vec<(String, Arc<Vec<u8>>, Option<String>)> = files+            .iter()+            .map(|(_, f, ranges, restricted)| {+                (title_of(&f.name).to_string(), f.bytes.clone(), (*restricted || ranges.len() > 1).then(|| ranges.join(", ")))+            })+            .collect();+        let passwords: Vec<Option<&str>> = files.iter().map(|(_, f, ..)| f.password.as_ref().map(|p| p.0.as_str())).collect();+        let count = files.len();+        match self.session.shuffle(&sources, &passwords) {+            Ok(shuffled) => {+                self.close_combine_tab();+                let message = crate::i18n::fmt(tl!("Interleaved {n} files"), &[("n", &count.to_string())]);+                self.open_created("Interleaved.pdf", shuffled.bytes, &message)+            }+            // The list stays, so the user can fix it.+            Err(e) => self.notify_fmt("Couldn't interleave files: {e}", &[("e", &e.to_string())]),+        }+    }+     /// Combine the listed files into a new, unsaved document; the tab makes way for it.     /// Files that can't be read are left out (the user was asked).     pub fn combine_staged(&mut self) {diff --git a/crates/ui-egui/src/workflows/combine_ui/view.rs b/crates/ui-egui/src/workflows/combine_ui/view.rs--- a/crates/ui-egui/src/workflows/combine_ui/view.rs+++ b/crates/ui-egui/src/workflows/combine_ui/view.rs@@ -72,6 +72,14 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {         app.combine_tab.zoom_request = None;     }     let mut combine = false;+    let mut interleave = false;+    // Interleaving takes whole files, none left out: one that can't be read stops it, as it+    // stops Combine until the user has chosen to leave it out.+    let interleave_blocker = blocker.clone().or_else(|| {+        app.combine_draft.iter().find(|f| f.unreadable()).map(|f| {+            crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", f.problem.as_deref().unwrap_or_default())])+        })+    });      egui::Frame::NONE.inner_margin(egui::Margin::symmetric(28, 20)).show(ui, |ui| {         // Title and Combine.@@ -85,6 +93,21 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {                 if response.clicked() {                     combine = true;                 }+                // Alternate the files' pages, as a scan made front sides first and backs second needs (#4922).+                let shuffle = ui+                    .add_enabled_ui(interleave_blocker.is_none(), |ui| widgets::icon_pill(ui, "arrow-left-right", tl!("Interleave pages"), false))+                    .inner;+                match &interleave_blocker {+                    Some(why) => {+                        shuffle.clone().on_disabled_hover_text(why);+                    }+                    None => {+                        shuffle.clone().on_hover_text(tl!("Alternate the pages of the files: page 1 of each, then page 2 of each, and so on."));+                    }+                }+                if shuffle.clicked() {+                    interleave = true;+                }             });         });         let hint = if grid {@@ -265,6 +288,9 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {     if let Some(action) = events.action {         app.combine_apply(action);     }+    if interleave && interleave_blocker.is_none() {+        app.interleave_staged();+    }     if combine && blocker.is_none() {         if app.combine_draft.iter().any(CombineFile::unreadable) {             app.combine_tab.confirm_skip = true;diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs--- a/crates/ui-egui/tests/it/combine_grid.rs+++ b/crates/ui-egui/tests/it/combine_grid.rs@@ -528,6 +528,85 @@ fn combine_follows_the_order_shown_in_the_grid() {     assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["three", "one", "two"]); } +/// #4922: Interleave pages alternates the pages of the listed files, in the order shown, and the+/// result opens as one new document with a bookmark per file.+#[test]+fn interleave_pages_alternates_the_pages_of_the_files_in_the_order_shown() {+    let mut h = grid_of_pages(&[("front.pdf", 3), ("back.pdf", 2)]);+    h.get_by_label("Interleave pages").click();+    h.run_steps(3);+    let app = h.state();+    assert_eq!(app.views.len(), 1, "the result opens, in place of the Combine tab");+    // Page 1 of each file, then page 2, then the rest of the longer one. (Both files' pages say+    // "Page n", so the order is the pages' numbers.)+    assert_eq!(texts_of(app, 0), ["Page 1", "Page 1", "Page 2", "Page 2", "Page 3"]);+    let doc = app.session.get(app.views[0].id).unwrap();+    assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::<Vec<_>>(), ["front", "back"]);+}++/// #4922: the Pages chosen for a file are the pages that are interleaved, not the whole file, and a+/// file split into parts is its parts one after the other.+#[test]+fn interleave_pages_takes_the_pages_the_list_gives_each_file() {+    let mut h = grid_of_pages(&[("front.pdf", 3), ("back.pdf", 3)]);+    // Only page 2 of the front, pages 3 then 1 of the back.+    h.state_mut().combine_draft[0].range = "2".into();+    h.state_mut().combine_draft[1].range = "3, 1".into();+    h.run_steps(2);+    h.get_by_label("Interleave pages").click();+    h.run_steps(3);+    // Round one: front 2, back 3; then the back's page 1, which the front has no page for.+    assert_eq!(texts_of(h.state(), 0), ["Page 2", "Page 3", "Page 1"]);++    // A file split in two parts (both parts' ranges, in the order shown) is one file of both.+    let mut h = grid_of_pages(&[("front.pdf", 4), ("back.pdf", 2)]);+    h.state_mut().combine_draft[0].range = "1".into();+    let mut part = h.state().combine_draft[0].clone();+    part.id += 1000;+    part.range = "4".into();+    h.state_mut().combine_draft.push(part);+    h.run_steps(2);+    h.get_by_label("Interleave pages").click();+    h.run_steps(3);+    // front's pages 1 then 4; back's 1 then 2: Page 1, Page 1, Page 4, Page 2.+    assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 4", "Page 2"]);++    // A part whose Pages is cleared is the whole file at that place, and the other parts stay:+    // front is 4, then 1 2 3 4 (the second part cleared); back is 1 2.+    let mut h = grid_of_pages(&[("front.pdf", 4), ("back.pdf", 2)]);+    h.state_mut().combine_draft[0].range = "4".into();+    let mut whole = h.state().combine_draft[0].clone();+    whole.id += 1000;+    whole.range = String::new();+    h.state_mut().combine_draft.push(whole);+    h.run_steps(2);+    h.get_by_label("Interleave pages").click();+    h.run_steps(3);+    // Round one: front 4, back 1; round two: front 1, back 2; then front 2, 3, 4.+    assert_eq!(texts_of(h.state(), 0), ["Page 4", "Page 1", "Page 1", "Page 2", "Page 2", "Page 3", "Page 4"]);++    // Two parts, both cleared: the whole file twice, as Combine places them.+    let mut h = grid_of_pages(&[("front.pdf", 3), ("back.pdf", 1)]);+    let mut again = h.state().combine_draft[0].clone();+    again.id += 1000;+    h.state_mut().combine_draft.push(again);+    h.run_steps(2);+    h.get_by_label("Interleave pages").click();+    h.run_steps(3);+    // Round one: front 1, back 1; then the rest of front's six pages: 2, 3, 1, 2, 3.+    assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "Page 3", "Page 1", "Page 2", "Page 3"]);+}++/// #4922: Interleave pages waits for two readable files, like Combine.+#[test]+fn interleave_pages_needs_two_files() {+    use egui_kittest::kittest::NodeT;+    let h = grid_of_pages(&[("only.pdf", 3)]);+    assert!(h.get_by_label("Interleave pages").accesskit_node().is_disabled(), "one file has nothing to interleave with");+    let h = grid_of_pages(&[("one.pdf", 1), ("two.pdf", 1)]);+    assert!(!h.get_by_label("Interleave pages").accesskit_node().is_disabled());+}+ #[test] fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() {     if crate::support::skip_without_glyphs("long_and_unusual_names_are_shortened_on_the_card_but_kept_whole", "en", "日本語のファイル名.pdf")diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -70,14 +70,14 @@ The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same on config works across the suite) keep the agent to the folders you give it:  - Every file a tool reads must lie inside the **read root**: `doc_open`, `page_insert_file`,-  `page_replace`, `doc_combine` and `doc_create_multiple` inputs, `doc_create` images, stamp,+  `page_replace`, `doc_combine`, `doc_shuffle` and `doc_create_multiple` inputs, `doc_create` images, stamp,   picture, attachment, signature and form-image files, `doc_watermark` and `doc_background` files,   `doc_import_data`, `form_merge_data`, the files `action_run` and `ocr_recognize_files` process,   digital ID, graphic and certificate files for signing and `sign_trust`, `text_font_import`, and   the folders `text_font_files` searches. Without `directories`, `text_font_files` searches the read   root instead of the installed fonts. - Every file a tool writes must lie inside the **write root**: `doc_save` (also saving in place),-  `page_extract`, `doc_combine`, `doc_create_multiple`, `doc_split`, `doc_reduce`, `doc_optimize`,+  `page_extract`, `doc_combine`, `doc_shuffle`, `doc_create_multiple`, `doc_split`, `doc_reduce`, `doc_optimize`,   `doc_print` to a file, the `doc_export_*` tools, `image_save`, `measure_export`,   `comments_summarize`, the accessibility report and review record, `doc_compare_report`,   `form_merge_data`, the output folders of `action_run` and `ocr_recognize_files`, `sign_id_create`diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -3006,6 +3006,18 @@ tools = ["doc_combine"] evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." +[[feature]]+id = "organize.interleave-files"+title = "Interleave the pages of two or more files (shuffle)"+area = "D"+tier = "P2"+milestone = "M4"+status = "shipped"+commands = []+tools = ["doc_shuffle"]+evidence = ["crates/engine/src/tests.rs::shuffle_interleaves_the_pages_of_files", "crates/engine/src/tests.rs::shuffle_refuses_more_pages_than_its_cap", "crates/organize/src/tests.rs::reorder_pages_puts_every_page_where_the_order_says", "crates/organize/src/tests.rs::reorder_pages_refuses_anything_but_a_permutation_and_changes_nothing", "crates/automation/tests/it/automation.rs::shuffle_interleaves_files_through_the_tool", "crates/ui-egui/tests/it/combine_grid.rs::interleave_pages_alternates_the_pages_of_the_files_in_the_order_shown", "crates/ui-egui/tests/it/combine_grid.rs::interleave_pages_needs_two_files"]+notes = "Page 1 of each file in turn, then page 2 of each, and so on, as `pdftk A B shuffle` does (#4922); a file that runs out drops out and the longer ones carry on, so five fronts and five backs of a two-pass duplex scan come out in reading order. The Combine files tab has an Interleave pages button (the list's order; a file is the pages its Pages field and its parts give it, in the order shown; one that can't be read blocks it); `doc_shuffle` takes two or more paths (images and text files are made into PDFs first, `pages` chooses each file's pages, passwords by position, `out`/`open` as Combine, and colliding form field names are renamed and reported as `renamed_fields`). The files are combined whole, then the pages are moved once into their new places (linear: two 10,000-page files take half a second). Each file keeps one bookmark at its first page; at most 100,000 pages in all. Not a general page-order editor. A back side scanned last page first is interleaved by giving its Pages field a descending range (5-1 lists the pages 5, 4, 3, 2, 1); there is no separate reverse option."+ [[feature]] id = "organize.combine-bookmarks" title = "Combine adds a bookmark per file with its own bookmarks nested"