From 58d368f1f07877ba99fa7296b4ab381f8a5599b5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:11:00 +0800 Subject: [PATCH 01/33] M10: Word export keeps an italic run inside its sentence's paragraph (#4158) (#4411) The reading blocks keep style boundaries, so "Before Chevron after." with an italic case name reached the Office exporters as three blocks, and each writer emitted one paragraph per block. A block that continues another on the same line (the test `continuations` already applies to keep such blocks out of table cells) is now a run in its own size, bold/italic and colour inside the paragraph of the block it continues, in Word, HTML, RTF and PowerPoint. - `continued_from` names the block each one continues (nearest end); `continuations` is its `is_some`, so table detection is unchanged. - `paragraphs` groups blocks left to right, joining only a block that starts strictly further left, with the same rotation, and never a table cell or the slide title. - Block text is trimmed, so the engine records the source's spaces at its ends (`Block::space_around`, from the reading block's editor text); runs keep that space, or one where the page shows a gap. - A paragraph is a heading only if its runs are (a bold lead-in no longer makes its line a heading); a smaller footnote marker does not demote a heading. Style changes that wrap onto the next line still split paragraphs; that needs the reading-block grouping change and is left for later. Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/automation/tests/it/automation.rs | 64 ++++ crates/engine/src/compare.rs | 3 + crates/export/src/lib.rs | 372 +++++++++++++++++++++-- crates/export/src/pptx.rs | 38 ++- parity/acrobat-features.toml | 6 +- 6 files changed, 436 insertions(+), 48 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9fe97284ca..edc0ad434a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9ea6f7f17b..23351db9aa 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6889,6 +6889,70 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET"; assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. }))); } +/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an +/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves. +#[test] +fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() { + let dir = workdir("italic-run-word"); + let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \ +BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("opinion.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" })); + let zip = std::fs::read(dir.join("opinion.docx")).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + // The paragraphs that hold text, each as its runs: (text, italic). + let paragraphs: Vec> = xml + .split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| Some((r.split_once("")?.1.split_once("")?.0.to_string(), r.contains("")))) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect(); + let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}")); + assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}"); + assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::(), "Before Chevron after.", "{xml}"); + assert_eq!(sentence.iter().map(|(_, i)| *i).collect::>(), [false, true, false], "{xml}"); + assert_eq!(paragraphs.len(), 2, "{xml}"); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 1326ccb5e8..044e40e2e2 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -220,7 +220,10 @@ impl crate::Document { .filter(|b| !b.text.trim().is_empty()) .map(|b| { let f = b.base_font.to_ascii_lowercase(); + // `text` is trimmed; the editor text keeps the source's own spaces. + let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)]; pdfcraft_export::Block { + space_around, text: b.text, rect: visible_rect(b.rect), size: b.size * unit, diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1303254d66..a9c43fb423 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,7 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text. Word keeps pages with +//! document: headings are the paragraphs set larger than the body text, and a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph. +//! Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -30,6 +32,10 @@ pub struct Block { pub color: [f64; 3], /// Counterclockwise baseline angle in user space (degrees). pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], } /// An image of a page. @@ -81,7 +87,8 @@ pub struct Table { /// What goes into the output, in order. enum Item<'a> { - Para(&'a Block, u8), + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), /// Owned: tables are detected per page inside [`items`], so they cannot borrow. Table(Table), Img(&'a Image), @@ -149,6 +156,12 @@ const CONTINUATION_PROBE: usize = 64; /// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running /// on (a link inside a sentence, a superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); blocks @@ -162,11 +175,88 @@ fn continuations(blocks: &[Block]) -> Vec { .iter() .take_while(|(end, _)| *end <= b.rect[0] + 1.0) .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) - .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) }) .collect() } + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -374,12 +464,14 @@ fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - let mut parts: Vec<(f64, f64, Item)> = p - .blocks - .iter() - .enumerate() - .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) - .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) .collect(); parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); @@ -434,15 +526,26 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi ); for it in items(pages) { match it { - Item::Para(b, lvl) => { - let mut t = esc(&b.text); - if b.italic { - t = format!("{t}"); + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); } - if b.bold && lvl == 0 { - t = format!("{t}"); - } - let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); match lvl { 0 => s.push_str(&format!("{t}

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

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

Before Chevron after.

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

Before

") && h.contains("

SIDE

"), "{h}"); + } + /// A block that starts where the line above ends is the next line, not a run of it. + #[test] + fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); + } + + /// A superscript beside a table cell stays in the table: table detection consumes it before + /// paragraphs are formed, and a sentence after the table still keeps its italic run. + #[test] + fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); + } + + /// A bold lead-in followed by body text on its line is body text, not a heading; a heading + /// with a smaller footnote marker after it stays a heading. + #[test] + fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); + } } diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 886a4bdbde..2edc0e25c9 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -1,11 +1,12 @@ //! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page, //! largest text as slide title, remaining paragraphs as body text, images and tables between them. -use crate::{Block, Image, Page, Table, Zip, esc, hex}; +use crate::{Image, Page, Run, Table, Zip, esc, hex}; /// Items on a slide in reading order. enum SlideBodyItem<'a> { - Para(&'a Block), + /// A paragraph's runs (at least one). + Para(Vec>), Table(Table), Img(&'a Image), } @@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .map(|(i, _)| i); let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); - for (bi, b) in p.blocks.iter().enumerate() { - if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { - continue; - } - items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + // The title is a shape of its own, so it joins no paragraph. + let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); + for runs in crate::paragraphs(&p.blocks, &skip) { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } for t in tables { items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); @@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { - let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); - paras_xml.push_str(&format!("{run}")); - let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); - est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + paras_xml.push_str(""); + for r in runs { + paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + paras_xml.push_str(""); + let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add); + let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max); + let lines = (len as f64 / 60.0).ceil().max(1.0); + est_h_pt += (size * 1.3) * lines + 6.0; i += 1; } let box_h = height_emu(est_h_pt); @@ -354,6 +360,7 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" #[cfg(test)] mod tests { use super::*; + use crate::Block; use crate::tests::part; fn sample_page() -> Page { @@ -369,6 +376,7 @@ mod tests { italic: false, color: [0.1, 0.2, 0.3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Revenue grew 25% year over year across all regions.".into(), @@ -378,6 +386,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Operational expenses remained flat.".into(), @@ -387,6 +396,7 @@ mod tests { italic: true, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, ], images: vec![Image { @@ -510,6 +520,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let table_page = Page { width: 612.0, @@ -611,6 +622,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let mut blocks = vec![huge("Title", f64::MAX, 500.0)]; for k in 0..40 { @@ -682,6 +694,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let p = Page { width: 720.0, @@ -696,6 +709,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, // Table where one cell has a very large number (e.g. 36 pt callout in the table) cell("Metric", 72.0, 400.0, 100.0, 12.0), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c688082202..c0bb4e60cf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7052,8 +7052,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7065,7 +7065,7 @@ status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" From 8708f35b37e2b0362a11b771943eab0dda260d36 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:11:04 +0800 Subject: [PATCH 02/33] M6: with NeedAppearances, check boxes and radio buttons draw their /MK caption (ISO 32000-2 Table 224) (#4454) * M6: with NeedAppearances, check boxes and radio buttons draw their /MK caption (ISO 32000-2 Table 224) A form that sets /NeedAppearances asks the viewer to construct the appearances of its widgets (ISO 32000-2 Table 224; deprecated in PDF 2.0). The display copy already redraws text and choice fields for it, but check boxes and radio buttons kept the file's own appearance streams, so a box whose /MK /CA is ZapfDingbats 4 (a check mark) showed the filled square its /AP carried. With /NeedAppearances, every check box and radio button that has an /MK is now drawn in its current state through button_state_shown (merged in #4368): /MK /BG and /BC, and when on the /MK /CA ZapfDingbats character (Table 192) in the /DA colour, sized to the box. A widget without /MK keeps its own appearance, and without /NeedAppearances nothing changes. A radio button with no /AS now takes its state from /V when /V names its own on state. Display and print only: the file is saved as it was. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC * chore(forms): wrap button_state_shown's doc comment Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/tests.rs | 67 ++++++++++++++++++++++++++++++ crates/forms/README.md | 4 +- crates/forms/src/appearance.rs | 7 ++-- crates/forms/src/lib.rs | 17 +++++--- crates/forms/src/tests.rs | 76 ++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 164 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 097a934f8e..360517a87c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8652,3 +8652,70 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); bytes } + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 2c24ddc6cf..93b65bd14e 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -60,7 +60,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in one field redraws that field, not the form. Check boxes and radio buttons with no appearance for their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats - symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. + symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`, + over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and + their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 49fa21e76c..c1bb5630a6 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: } /// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when -/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and -/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes -/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// the file has no appearance for that state, or leaves its appearances to the viewer +/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption +/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may +/// use too) as a ZapfDingbats character, as in the check box example of /// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is /// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most /// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index ffd67899cc..39cebd8fdd 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -1553,8 +1553,9 @@ pub fn need_appearances(doc: &Document) -> bool { /// writer left the appearances to the viewer), and otherwise each widget without a normal /// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons /// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not -/// changed: saving writes the file as it was. +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is +/// not changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1572,19 +1573,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance .and_then(|d| d.get(b"AP")) .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else a check box's value, else Off. An Off state may go without one (it shows - // nothing); an on state needs one. + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. let state = match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) } _ => None, }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); let missing = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, FieldKind::PushButton => normal.is_none(), FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, (None, _) => true, (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), _ => false, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 903e8b6fcd..61dab131e0 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4360,6 +4360,82 @@ fn comb_fields_show_their_cells() { assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } +/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that +/// sets `/NeedAppearances` (`need`) or not. +fn mk_buttons(need: bool) -> Document { + let flag = if need { "/NeedAppearances true " } else { "" }; + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + format!( + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>" + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6 + widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))), + widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))), + widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))), + widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10 + widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))), + widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))), + widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))), + widget( + "odd", + &format!( + "/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}", + states("Yes") + ), + ), + "<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + ]) +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it, +/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour +/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched. +#[test] +fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() { + let doc = mk_buttons(true); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, state, symbol, colour) in [ + ("check", "Yes", "(4) Tj", "0 g"), + ("cross", "Yes", "(8) Tj", "1 0 0 rg"), + ("diamond", "Yes", "(u) Tj", "0 g"), + ("square", "Yes", "(n) Tj", "0 g"), + ("star", "Yes", "(H) Tj", "0 g"), + ("radio", "B", "(l) Tj", "0 g"), + ] { + let shown = state_ap(©, &field(&all, name).widgets[0], state); + assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}"); + assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}"); + } + let check = state_ap(©, &field(&all, "check").widgets[0], "Yes"); + assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}"); + assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border"); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}"); + // Nonsense in /MK, /DA and /BS: the default check, no border, and no panic. + let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes"); + assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}"); + let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look"); + assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched"); +} + +/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown +/// as they are, `/MK` or not: no copy is needed. +#[test] +fn without_need_appearances_buttons_keep_their_own_appearances() { + assert!(with_field_appearances(&mk_buttons(false)).is_none()); +} + /// A form for filling many values at once: two summed fields and their calculated total, a /// name two field dictionaries share, a check box, a radio group, a list and a read-only field. fn batch_form() -> Document { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c0bb4e60cf..de678e6fa1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" From 71b47b519d2c295c2068dc89a8d75690fc7dd7e5 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:11:08 +0100 Subject: [PATCH 03/33] M3: a Page tools menu in the Pages panel (#4415) (#4583) * M3: a Page tools menu in the Pages panel (#4415) The Pages panel's header gets a Page tools menu beside Close: insert a blank page or pages from a file after the selection, rotate either way, extract and delete. Each runs the registered command on the selected thumbnails (the current page when none is) and is greyed out where the document's security withholds page changes. A menu rather than a row of buttons, so the thumbnails stay where they were and it fits any width. * M3: tests drive the Page tools menu's file items too (#4415) CodeRabbit on #4583: the parity evidence covered rotate, delete, blank page and permissions through the menu, not Insert pages from a file or Extract. A test now inserts a picked file's pages and extracts the selection from the menu; the parity entry cites it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM * M3: the Page tools menu in every catalog (#4415) Page tools and five of its items, in each language (AI-assisted, not separately native-reviewed, like the other recent entries). Insert a blank page after the selection was in the catalogs already and keeps its translation there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM * chore(ui): keep thumbnail_size's doc line on thumbnail_size Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/ar.tsv | 8 ++ crates/ui-egui/src/i18n/bg.tsv | 8 ++ crates/ui-egui/src/i18n/ca.tsv | 8 ++ crates/ui-egui/src/i18n/ckb.tsv | 8 ++ crates/ui-egui/src/i18n/cs.tsv | 8 ++ crates/ui-egui/src/i18n/de.tsv | 8 ++ crates/ui-egui/src/i18n/el.tsv | 8 ++ crates/ui-egui/src/i18n/es.tsv | 8 ++ crates/ui-egui/src/i18n/fi.tsv | 8 ++ crates/ui-egui/src/i18n/fr.tsv | 8 ++ crates/ui-egui/src/i18n/hi.tsv | 8 ++ crates/ui-egui/src/i18n/hu.tsv | 8 ++ crates/ui-egui/src/i18n/id.tsv | 8 ++ crates/ui-egui/src/i18n/it.tsv | 8 ++ crates/ui-egui/src/i18n/ja.tsv | 8 ++ crates/ui-egui/src/i18n/kk.tsv | 8 ++ crates/ui-egui/src/i18n/ko.tsv | 8 ++ crates/ui-egui/src/i18n/lo.tsv | 8 ++ crates/ui-egui/src/i18n/lt.tsv | 8 ++ crates/ui-egui/src/i18n/nb.tsv | 8 ++ crates/ui-egui/src/i18n/pl.tsv | 8 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 ++ crates/ui-egui/src/i18n/ro.tsv | 8 ++ crates/ui-egui/src/i18n/ru.tsv | 8 ++ crates/ui-egui/src/i18n/sl.tsv | 8 ++ crates/ui-egui/src/i18n/sq.tsv | 8 ++ crates/ui-egui/src/i18n/te.tsv | 8 ++ crates/ui-egui/src/i18n/th.tsv | 8 ++ crates/ui-egui/src/i18n/tr.tsv | 8 ++ crates/ui-egui/src/i18n/uk.tsv | 8 ++ crates/ui-egui/src/i18n/vi.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 8 ++ crates/ui-egui/src/panels.rs | 37 +++++++ crates/ui-egui/tests/it/view.rs | 98 +++++++++++++++++++ .../ui-egui/tests/snapshots/panel_pages.txt | 1 + parity/acrobat-features.toml | 4 +- 37 files changed, 402 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 25ddac1d5f..8b951d6f6d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2816,3 +2816,11 @@ certificate Version الإصدار # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today اليوم @plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools أدوات الصفحات + Insert pages from a file after the selection… إدراج صفحات من ملف بعد التحديد… + Rotate the selected pages counterclockwise تدوير الصفحات المحددة عكس اتجاه عقارب الساعة + Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة + Extract the selected pages… استخراج الصفحات المحددة… + Delete the selected pages حذف الصفحات المحددة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a8af8ed655..c3a692c495 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2503,3 +2503,11 @@ animation effects Off Изключени # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Днес @plural {n} reply|{n} replies {n} отговор|{n} отговора + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменти за страници + Insert pages from a file after the selection… Вмъкване на страници от файл след избраното… + Rotate the selected pages counterclockwise Завъртане на избраните страници обратно на часовниковата стрелка + Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка + Extract the selected pages… Извличане на избраните страници… + Delete the selected pages Изтриване на избраните страници diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 42b036d161..cc888dc973 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2801,3 +2801,11 @@ theme Appearance Aspecte Delete attachment Elimina l'adjunt Delete attachment {key} Elimina l'adjunt {key} @plural {n} reply|{n} replies {n} resposta|{n} respostes + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Eines de pàgina + Insert pages from a file after the selection… Insereix pàgines d'un fitxer després de la selecció… + Rotate the selected pages counterclockwise Gira les pàgines seleccionades en sentit antihorari + Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari + Extract the selected pages… Extreu les pàgines seleccionades… + Delete the selected pages Suprimeix les pàgines seleccionades diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 845f437845..db8fc1dab2 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2732,3 +2732,11 @@ certificate Version وەشان # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ @plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ئامرازەکانی پەڕە + Insert pages from a file after the selection… پەڕەکان لە فایلێکەوە لە دوای هەڵبژاردەکە دابنێ… + Rotate the selected pages counterclockwise پەڕە هەڵبژێردراوەکان پێچەوانەی میلی کاتژمێر بسوڕێنە + Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە + Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… + Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3cadb98e3b..28fb4e1d65 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2847,3 +2847,11 @@ certificate Version Verze # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dnes @plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Nástroje stránek + Insert pages from a file after the selection… Vložit stránky ze souboru za výběr… + Rotate the selected pages counterclockwise Otočit vybrané stránky proti směru hodinových ručiček + Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček + Extract the selected pages… Extrahovat vybrané stránky… + Delete the selected pages Odstranit vybrané stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b9dcc06f0..bc202ef1ab 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2541,3 +2541,11 @@ animation effects Off Aus # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Heute @plural {n} reply|{n} replies {n} Antwort|{n} Antworten + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Seitenwerkzeuge + Insert pages from a file after the selection… Seiten aus einer Datei nach der Auswahl einfügen… + Rotate the selected pages counterclockwise Ausgewählte Seiten gegen den Uhrzeigersinn drehen + Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen + Extract the selected pages… Ausgewählte Seiten extrahieren… + Delete the selected pages Ausgewählte Seiten löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dbe7969173..ebe7969656 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,3 +2546,11 @@ animation effects Off Ανενεργά # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Σήμερα @plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Εργαλεία σελίδων + Insert pages from a file after the selection… Εισαγωγή σελίδων από αρχείο μετά την επιλογή… + Rotate the selected pages counterclockwise Περιστροφή των επιλεγμένων σελίδων αριστερόστροφα + Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα + Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… + Delete the selected pages Διαγραφή των επιλεγμένων σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 951a3dd8c1..c839eb330a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2863,3 +2863,11 @@ theme Appearance Aspecto # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoy @plural {n} reply|{n} replies {n} respuesta|{n} respuestas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Herramientas de página + Insert pages from a file after the selection… Insertar páginas de un archivo después de la selección… + Rotate the selected pages counterclockwise Girar las páginas seleccionadas en sentido antihorario + Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario + Extract the selected pages… Extraer las páginas seleccionadas… + Delete the selected pages Eliminar las páginas seleccionadas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cd5a8040de..a876b8aabc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2810,3 +2810,11 @@ theme Appearance Teema Delete attachment Poista liite Delete attachment {key} Poista liite {key} @plural {n} reply|{n} replies {n} vastaus|{n} vastausta + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sivutyökalut + Insert pages from a file after the selection… Lisää sivuja tiedostosta valinnan jälkeen… + Rotate the selected pages counterclockwise Kierrä valittuja sivuja vastapäivään + Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään + Extract the selected pages… Pura valitut sivut… + Delete the selected pages Poista valitut sivut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ea6008039f..bb935754b3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2806,3 +2806,11 @@ certificate Version Version # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Aujourd'hui @plural {n} reply|{n} replies {n} réponse|{n} réponses + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Outils de page + Insert pages from a file after the selection… Insérer des pages d'un fichier après la sélection… + Rotate the selected pages counterclockwise Faire pivoter les pages sélectionnées dans le sens antihoraire + Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire + Extract the selected pages… Extraire les pages sélectionnées… + Delete the selected pages Supprimer les pages sélectionnées diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7f403caba1..8b1de38dc3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2777,3 +2777,11 @@ theme Appearance स्वरूप Delete attachment अनुलग्नक हटाएँ Delete attachment {key} अनुलग्नक {key} हटाएँ @plural {n} reply|{n} replies {n} जवाब + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools पृष्ठ उपकरण + Insert pages from a file after the selection… चयन के बाद किसी फ़ाइल से पृष्ठ डालें… + Rotate the selected pages counterclockwise चयनित पृष्ठों को वामावर्त घुमाएँ + Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ + Extract the selected pages… चयनित पृष्ठ निकालें… + Delete the selected pages चयनित पृष्ठ हटाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 33ced9925d..71bf7d36c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,3 +2539,11 @@ animation effects Off Ki # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Ma @plural {n} reply|{n} replies {n} válasz|{n} válasz + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Oldaleszközök + Insert pages from a file after the selection… Oldalak beszúrása fájlból a kijelölés után… + Rotate the selected pages counterclockwise A kijelölt oldalak forgatása az óramutató járásával ellentétesen + Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően + Extract the selected pages… A kijelölt oldalak kibontása… + Delete the selected pages A kijelölt oldalak törlése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 422fac2793..409a04cf46 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2778,3 +2778,11 @@ theme Appearance Tampilan Delete attachment Hapus lampiran Delete attachment {key} Hapus lampiran {key} @plural {n} reply|{n} replies {n} balasan + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Alat halaman + Insert pages from a file after the selection… Sisipkan halaman dari file setelah pilihan… + Rotate the selected pages counterclockwise Putar halaman yang dipilih berlawanan arah jarum jam + Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam + Extract the selected pages… Ekstrak halaman yang dipilih… + Delete the selected pages Hapus halaman yang dipilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 60ecf2de3c..11a9d4869b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2772,3 +2772,11 @@ certificate SHA-256 digest Impronta SHA-256 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Oggi @plural {n} reply|{n} replies {n} risposta|{n} risposte + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Strumenti pagina + Insert pages from a file after the selection… Inserisci pagine da un file dopo la selezione… + Rotate the selected pages counterclockwise Ruota le pagine selezionate in senso antiorario + Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario + Extract the selected pages… Estrai le pagine selezionate… + Delete the selected pages Elimina le pagine selezionate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index db338ebb11..1a8c287bcf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2540,3 +2540,11 @@ animation effects Off オフ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今日 @plural {n} reply|{n} replies {n} 件の返信 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ページツール + Insert pages from a file after the selection… 選択範囲の後にファイルからページを挿入… + Rotate the selected pages counterclockwise 選択したページを反時計回りに回転 + Rotate the selected pages clockwise 選択したページを時計回りに回転 + Extract the selected pages… 選択したページを抽出… + Delete the selected pages 選択したページを削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index ea0d0d0fb4..a9eada9743 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2538,3 +2538,11 @@ animation effects Off Өшірулі # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Бүгін @plural {n} reply|{n} replies {n} жауап|{n} жауап + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Бет құралдары + Insert pages from a file after the selection… Таңдаудан кейін файлдан беттер кірістіру… + Rotate the selected pages counterclockwise Таңдалған беттерді сағат тіліне қарсы бұру + Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру + Extract the selected pages… Таңдалған беттерді шығарып алу… + Delete the selected pages Таңдалған беттерді жою diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a9ee5b4878..98b68e85c7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2777,3 +2777,11 @@ theme Appearance 모양 Delete attachment 첨부 파일 삭제 Delete attachment {key} 첨부 파일 {key} 삭제 @plural {n} reply|{n} replies 답글 {n}개 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 페이지 도구 + Insert pages from a file after the selection… 선택 영역 뒤에 파일에서 페이지 삽입… + Rotate the selected pages counterclockwise 선택한 페이지를 시계 반대 방향으로 회전 + Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 + Extract the selected pages… 선택한 페이지 추출… + Delete the selected pages 선택한 페이지 삭제 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 3b702756bc..331b24501b 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2448,3 +2448,11 @@ animation effects Off ປິດ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ @plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ເຄື່ອງມືໜ້າ + Insert pages from a file after the selection… ແຊກໜ້າຈາກໄຟລ໌ຫຼັງການເລືອກ… + Rotate the selected pages counterclockwise ໝູນໜ້າທີ່ເລືອກທວນເຂັມໂມງ + Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ + Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… + Delete the selected pages ລຶບໜ້າທີ່ເລືອກ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 058bbe9052..2c151b2554 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2801,3 +2801,11 @@ theme Appearance Išvaizda # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Šiandien @plural {n} reply|{n} replies {n} atsakymas|{n} atsakymai|{n} atsakymų + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Puslapių įrankiai + Insert pages from a file after the selection… Įterpti puslapius iš failo po pasirinkimo… + Rotate the selected pages counterclockwise Pasukti pasirinktus puslapius prieš laikrodžio rodyklę + Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę + Extract the selected pages… Išskleisti pasirinktus puslapius… + Delete the selected pages Ištrinti pasirinktus puslapius diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index bc5fc98f58..75c926a0e3 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2716,3 +2716,11 @@ key Space Mellomrom # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Gjenopprett den siste visningen (side, zoom og sidevisning) når et dokument åpnes igjen @plural {n} reply|{n} replies {n} svar|{n} svar + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sideverktøy + Insert pages from a file after the selection… Sett inn sider fra en fil etter utvalget… + Rotate the selected pages counterclockwise Roter de valgte sidene mot klokken + Rotate the selected pages clockwise Roter de valgte sidene med klokken + Extract the selected pages… Trekk ut de valgte sidene… + Delete the selected pages Slett de valgte sidene diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 056ca7d403..7d76739499 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2636,3 +2636,11 @@ animation effects Off Wyłączone # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dzisiaj @plural {n} reply|{n} replies {n} odpowiedź|{n} odpowiedzi|{n} odpowiedzi + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Narzędzia stron + Insert pages from a file after the selection… Wstaw strony z pliku po zaznaczeniu… + Rotate the selected pages counterclockwise Obróć zaznaczone strony w lewo + Rotate the selected pages clockwise Obróć zaznaczone strony w prawo + Extract the selected pages… Wyodrębnij zaznaczone strony… + Delete the selected pages Usuń zaznaczone strony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1434c7c6e2..371c8cc142 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2825,3 +2825,11 @@ theme Appearance Aparência # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoje @plural {n} reply|{n} replies {n} resposta|{n} respostas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Ferramentas de página + Insert pages from a file after the selection… Inserir páginas de um arquivo após a seleção… + Rotate the selected pages counterclockwise Girar as páginas selecionadas no sentido anti-horário + Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário + Extract the selected pages… Extrair as páginas selecionadas… + Delete the selected pages Excluir as páginas selecionadas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 7c739932e2..09b313e734 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2801,3 +2801,11 @@ theme Appearance Aspect Delete attachment Ștergere atașament Delete attachment {key} Ștergere atașament {key} @plural {n} reply|{n} replies {n} răspuns|{n} răspunsuri|{n} de răspunsuri + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Instrumente pentru pagini + Insert pages from a file after the selection… Inserează pagini dintr-un fișier după selecție… + Rotate the selected pages counterclockwise Rotește paginile selectate în sens antiorar + Rotate the selected pages clockwise Rotește paginile selectate în sens orar + Extract the selected pages… Extrage paginile selectate… + Delete the selected pages Șterge paginile selectate diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bea98cd797..0523fe70cc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2453,3 +2453,11 @@ animation effects Off Выкл # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сегодня @plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменты страниц + Insert pages from a file after the selection… Вставить страницы из файла после выделения… + Rotate the selected pages counterclockwise Повернуть выбранные страницы против часовой стрелки + Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке + Extract the selected pages… Извлечь выбранные страницы… + Delete the selected pages Удалить выбранные страницы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index ccbb42d57b..3a7ea48c25 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2545,3 +2545,11 @@ animation effects Off Izklopljeni # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Danes @plural {n} reply|{n} replies {n} odgovor|{n} odgovora|{n} odgovori|{n} odgovorov + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Orodja za strani + Insert pages from a file after the selection… Vstavi strani iz datoteke za izborom… + Rotate the selected pages counterclockwise Zavrti izbrane strani v nasprotni smeri urnega kazalca + Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca + Extract the selected pages… Izvleci izbrane strani… + Delete the selected pages Izbriši izbrane strani diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index a720a5333f..70a6fbad77 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2527,3 +2527,11 @@ animation effects Off Joaktiv Delete attachment Fshi bashkëngjitjen Delete attachment {key} Fshi bashkëngjitjen {key} @plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Mjetet e faqes + Insert pages from a file after the selection… Fut faqe nga një skedar pas përzgjedhjes… + Rotate the selected pages counterclockwise Rrotullo faqet e përzgjedhura në drejtim të kundërt me orën + Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës + Extract the selected pages… Nxirr faqet e përzgjedhura… + Delete the selected pages Fshi faqet e përzgjedhura diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 317d47f9b9..2c8808aa40 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2449,3 +2449,11 @@ animation effects Off ఆఫ్ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు @plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools పేజీ సాధనాలు + Insert pages from a file after the selection… ఎంపిక తర్వాత ఫైల్ నుండి పేజీలను చొప్పించు… + Rotate the selected pages counterclockwise ఎంచుకున్న పేజీలను అపసవ్య దిశలో తిప్పు + Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు + Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… + Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 92bb15f1ad..597a0dbb9e 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2703,3 +2703,11 @@ theme Appearance ลักษณะที่ปรากฏ # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง @plural {n} reply|{n} replies การตอบกลับ {n} รายการ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools เครื่องมือหน้า + Insert pages from a file after the selection… แทรกหน้าจากไฟล์หลังส่วนที่เลือก… + Rotate the selected pages counterclockwise หมุนหน้าที่เลือกทวนเข็มนาฬิกา + Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา + Extract the selected pages… แยกหน้าที่เลือกออก… + Delete the selected pages ลบหน้าที่เลือก diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bcdfe9a90e..0d07fcc760 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2536,3 +2536,11 @@ animation effects Off Kapalı # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Bugün @plural {n} reply|{n} replies {n} yanıt + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sayfa araçları + Insert pages from a file after the selection… Seçimin ardından dosyadan sayfa ekle… + Rotate the selected pages counterclockwise Seçili sayfaları saat yönünün tersine döndür + Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür + Extract the selected pages… Seçili sayfaları ayıkla… + Delete the selected pages Seçili sayfaları sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 599b4b63af..7531c9a753 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2562,3 +2562,11 @@ animation effects Off Вимкнено # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сьогодні @plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Інструменти сторінок + Insert pages from a file after the selection… Вставити сторінки з файлу після виділення… + Rotate the selected pages counterclockwise Повернути вибрані сторінки проти годинникової стрілки + Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою + Extract the selected pages… Витягнути вибрані сторінки… + Delete the selected pages Видалити вибрані сторінки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 048f78f624..d9c375a6e2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2807,3 +2807,11 @@ key Space Space # AI-assisted additions for restoring the last view (#1767) (2026-10-16); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Khôi phục chế độ xem gần nhất (trang, thu phóng và hiển thị trang) khi mở lại tài liệu @plural {n} reply|{n} replies {n} câu trả lời + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Công cụ trang + Insert pages from a file after the selection… Chèn trang từ tệp sau vùng chọn… + Rotate the selected pages counterclockwise Xoay các trang đã chọn ngược chiều kim đồng hồ + Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ + Extract the selected pages… Trích xuất các trang đã chọn… + Delete the selected pages Xóa các trang đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7ea3ca941c..e2b1f78974 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2504,3 +2504,11 @@ animation effects Off 关 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @plural {n} reply|{n} replies {n} 条回复 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 页面工具 + Insert pages from a file after the selection… 在所选内容后插入文件中的页面… + Rotate the selected pages counterclockwise 逆时针旋转所选页面 + Rotate the selected pages clockwise 顺时针旋转所选页面 + Extract the selected pages… 提取所选页面… + Delete the selected pages 删除所选页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b34cb787ca..ecb5ff6231 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2490,3 +2490,11 @@ animation effects Off 關 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 @plural {n} reply|{n} replies {n} 則回覆 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 頁面工具 + Insert pages from a file after the selection… 在所選內容後插入檔案中的頁面… + Rotate the selected pages counterclockwise 逆時針旋轉所選頁面 + Rotate the selected pages clockwise 順時針旋轉所選頁面 + Extract the selected pages… 擷取所選頁面… + Delete the selected pages 刪除所選頁面 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f043a1e999..535ba2e083 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -730,6 +730,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() { close = true; } + if panel == RightPanel::Pages + && let Some(c) = page_tools(ui, !modal && view.panel_drag.is_none() && bm_editable) + { + panel_command = Some(c); + } // Right to left: close, "…", filter, search (Acrobat's order left to right). if panel == RightPanel::Comments { panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden()); @@ -1335,6 +1340,38 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte } } +/// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, +/// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its +/// command on the selected pages (the current page when none is), so it does what Pages ▸ … +/// does; the items say so, apart from the thumbnails' own menu. `enabled` is false while the +/// document may not be assembled or a dialog is open. Returns the command a click asks for. +fn page_tools(ui: &mut egui::Ui, enabled: bool) -> Option<&'static str> { + let tools: [(&'static str, &str); 6] = [ + ("page.insert_blank", tl!("Insert a blank page after the selection")), + ("page.insert", tl!("Insert pages from a file after the selection…")), + ("page.rotate_ccw", tl!("Rotate the selected pages counterclockwise")), + ("page.rotate", tl!("Rotate the selected pages clockwise")), + ("page.extract", tl!("Extract the selected pages…")), + ("page.delete", tl!("Delete the selected pages")), + ]; + let mut picked = None; + ui.add_enabled_ui(enabled, |ui| { + let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Page tools")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(260.0); + for (id, label) in tools { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + let item = egui::Button::image_and_text(icons::image(spec.icon, 16.0, ui.visuals().text_color()), label); + if ui.add(item).clicked() { + picked = Some(id); + ui.close(); + } + } + }); + }); + picked +} + /// Size controls stay above the scroll area, so they remain reachable on long documents. fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) { let before = view.thumbnail_zoom(); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 1e87d9a167..01e733e4d7 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2157,3 +2157,101 @@ fn full_screen_clicks_turn_pages_whatever_tool_was_on() { assert!(!h.state().full_screen); assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back"); } + +/// #4415: the Pages panel's Page tools menu. Each tool acts on the selected pages (the current +/// page when none is): rotate, delete and insert a blank page here, as Pages ▸ … does. +#[test] +fn the_pages_panel_tools_act_on_the_selected_pages() { + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let rotations = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::>(); + // The panel's Page tools menu, on the right-hand side of the window. + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + h.state_mut().views[0].select_pages(&[1, 2]); + h.run_steps(2); + tool(&mut h, "Rotate the selected pages clockwise"); + assert_eq!(rotations(&h), [0, 90, 90], "pages 2 and 3, the selection"); + tool(&mut h, "Rotate the selected pages counterclockwise"); + assert_eq!(rotations(&h), [0, 0, 0]); + h.state_mut().views[0].select_pages(&[0]); + h.run_steps(2); + tool(&mut h, "Delete the selected pages"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "page 1 deleted"); + tool(&mut h, "Insert a blank page after the selection"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "each is one undo step"); +} + +/// CodeRabbit on #4583: the Page tools menu's file items work from the menu too. Insert pages +/// from a file puts the picked file's pages after the selection; Extract the selected pages +/// opens Extract for the selection and writes it. +#[test] +fn the_pages_panel_tools_insert_from_a_file_and_extract() { + let dir = std::env::temp_dir().join(format!("pdfcraft-page-tools-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let more = dir.join("more.pdf"); + std::fs::write(&more, PAGES).unwrap(); + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.len(); + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + assert_eq!(count(&h), 3); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().pick_override = Some(vec![more.to_string_lossy().into_owned()]); + tool(&mut h, "Insert pages from a file after the selection…"); + h.run_steps(3); + assert_eq!(count(&h), 6, "the picked file's three pages are inserted"); + + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().views[0].select_pages(&[1, 2]); + tool(&mut h, "Extract the selected pages…"); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Extract), "Extract opens for the selection"); + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.get_all_by_label("Extract").last().unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 2, "the two selected pages, each a file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The Pages panel's Page tools are greyed out on a document whose security doesn't allow +/// page changes: the menu doesn't open, and nothing changes. +#[test] +fn the_pages_panel_tools_are_off_without_page_editing_permission() { + use egui_kittest::kittest::NodeT; + let mut h = harness(&[("panel", "pages")]); + { + let app = h.state_mut(); + app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { + permissions_password: Some("owner".into()), + changes: pdfcraft_engine::Changes::None, + ..Default::default() + })); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap(); + } + h.run_steps(6); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + assert!(!h.state().session.get(id).unwrap().allows_assembly()); + let tools = h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools"); + assert!(tools.accesskit_node().is_disabled(), "Page tools is greyed out"); + tools.click(); + h.run_steps(3); + assert!(h.query_by_label("Delete the selected pages").is_none(), "its menu doesn't open"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); +} diff --git a/crates/ui-egui/tests/snapshots/panel_pages.txt b/crates/ui-egui/tests/snapshots/panel_pages.txt index 4e746f1006..7f432057d9 100644 --- a/crates/ui-egui/tests/snapshots/panel_pages.txt +++ b/crates/ui-egui/tests/snapshots/panel_pages.txt @@ -1,6 +1,7 @@ Label = "Pages" Label = "2" Button "Close" [off] +Button "Page tools" [off] Label = "Thumbnail size" Label = "100%" Button "Smaller thumbnails" [off] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index de678e6fa1..9144112544 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1274,8 +1274,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] -notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." +evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_act_on_the_selected_pages", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_insert_from_a_file_and_extract", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_are_off_without_page_editing_permission"] +notes = "Page tools menu in the panel's header (#4415): insert a blank page or pages from a file after the selection, rotate either way, extract and delete, each on the selected pages (the current one when none is), greyed out where the document's security withholds page changes. Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" From d764a8ebca2dc289841b34ea3048f613a9869bbb Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:06:35 -0400 Subject: [PATCH 04/33] refactor(ui): side panels on craft-ui task panes and the shared rail (#4606) * refactor(ui): side panels on craft-ui task panes and the shared rail The Tools pane and the open side panel are docked craft-ui task panes (craft_ui::panes::PaneHost, Columns policy, no title bars, not movable), and the rail is craft_ui::panes::Rail with the page navigation cluster as its foot. On the right, outermost first: the rail, Tools when Preferences > Tools panel side is Right, the side panel, the page. Read mode uses the same panes without Tools; All tools full view still covers them. Floating, tab groups and drag-in are gone with docking.rs. - Widths are resizable within the old limits and saved under a new side_panels preference (version 1) with the Tools side and open state. The old docking value is read once as plain JSON and dropped on save. - ui.dock becomes ui.panel {panel: tools|side, open, width}; ui.dock.reset resets the side panels ("Reset side panels"). - F4 shows or hides the side panel (reopening the last one) and Shift+F4 the Tools panel, as View menu commands. - craft-ui pinned to e9630ae7 (craft-libs#309). * fix(ui): side panels clippy lint and the dragged-width test's grab offset * chore(deps): pin craft-ui at 7c42807, whose pane resize edges work over the page * chore(deps): pin craft-ui at craft-libs main ea4c823, with panes::Rail --- Cargo.lock | 8 +- Cargo.toml | 2 +- README.md | 27 +- crates/engine/src/commands.rs | 18 +- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/chrome.rs | 305 ++++++------ crates/ui-egui/src/commands.rs | 4 +- crates/ui-egui/src/control.rs | 16 +- crates/ui-egui/src/docking.rs | 547 --------------------- crates/ui-egui/src/icons.rs | 2 +- crates/ui-egui/src/js_ui.rs | 17 +- crates/ui-egui/src/lib.rs | 86 ++-- crates/ui-egui/src/side_panels.rs | 522 ++++++++++++++++++++ crates/ui-egui/tests/it/control.rs | 203 +++----- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/panel_snapshots.rs | 10 +- crates/ui-egui/tests/it/pointer.rs | 23 +- crates/ui-egui/tests/it/side_panels.rs | 133 +++++ crates/ui-egui/tests/it/ui.rs | 6 +- 19 files changed, 974 insertions(+), 959 deletions(-) delete mode 100644 crates/ui-egui/src/docking.rs create mode 100644 crates/ui-egui/src/side_panels.rs create mode 100644 crates/ui-egui/tests/it/side_panels.rs diff --git a/Cargo.lock b/Cargo.lock index 37f2487f8b..07b8e4ef03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1174,9 +1174,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] @@ -6463,7 +6467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.60.2", diff --git a/Cargo.toml b/Cargo.toml index 78c7a78c6f..6a91547f7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. diff --git a/README.md b/README.md index d39bec5aa9..f319ffeadb 100644 --- a/README.md +++ b/README.md @@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true pdfcraft-cli ui --control ~/.pdfcraft-control.json quit ``` -`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view. -Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`, -`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and -`zone: center|left|right|top|bottom`. For example, -`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools. -`ui.dock.reset` restores the default -arrangement. The control channel accepts these methods directly; `ui.command` also accepts -`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility -and floating geometry. Invalid requests return an error without changing the document or layout. +`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`. +`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last). +`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset` +resets the side panels: Tools open on the left, default widths. The control channel accepts these +methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep +the Tools side, the open panels and their widths. Invalid requests return an error without changing +the document or the panels. `resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only. @@ -595,13 +593,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. - -Advanced `ui.dock` operations use the same validated transaction as pointer gestures: -`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and -`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), -`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. -The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel -rules apply equally to both forms. +The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4ebfecafc7..1f25c23c98 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -231,7 +231,23 @@ pub const COMMANDS: &[CommandSpec] = &[ c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"), c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), - c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), + c( + "view.side_panel", + "Show or hide the side panel", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-right", + ), + c( + "view.tools_pane", + "Show or hide the Tools panel", + VIEW, + Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-left", + ), + c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), c("view.theme.system", "Use system setting", None, None, Nothing, "settings"), c("view.theme.light", "Light gray", None, None, Nothing, "sun"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 02bc000ed6..fc0ee07c68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2701,8 +2701,7 @@ const SUPPRESS_SECS: f64 = 0.25; /// Short rolling extension while egui still has an unprocessed wheel tail. const SUPPRESS_TAIL_SECS: f64 = 0.05; -// The viewer moves between docking and the central panel as modes change; keep its scroll -// identity tied to the document instead of either parent UI. +// Keep the viewer's scroll identity tied to the document instead of its parent UI. fn page_scroll( ui: &mut egui::Ui, scroll: egui::ScrollArea, diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2723d16c23..b7dd6b3ed8 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,5 +1,6 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. +use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; @@ -651,166 +652,164 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); let page_count = doc.info.pages.len(); let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); - egui::Panel::right("rail") - .resizable(false) - .exact_size(48.0) + let item = |panel: RightPanel, icon: &str, label: &str, marker: Option<&str>| { + let item = RailItem::new(panel, icons::source(icon), tl!(label)).marker(marker.is_some()); + match marker { + Some(marker) => item.marker_label(tl!(marker)), + None => item, + } + }; + let has = |has: bool, label: &'static str| has.then_some(label); + let mut items = vec![ + item(RightPanel::Comments, "message-square-text", "Comments", has(has_comments, "Has comments")), + item(RightPanel::Bookmarks, "bookmark", "Bookmarks", has(has_outline, "Has bookmarks")), + item(RightPanel::Pages, "files", "Page thumbnails", None), + item(RightPanel::Fields, "text-cursor-input", "Form fields", has(has_fields, "Has form fields")), + item(RightPanel::Layers, "layers", "Layers", has(has_layers, "Has layers")), + item(RightPanel::Attachments, "paperclip", "Attachments", has(has_files, "Has attachments")), + item(RightPanel::Signatures, "signature", "Signatures", has(has_signatures, "Signed")), + ]; + if has_check { + items.push(item(RightPanel::Accessibility, "accessibility", "Accessibility Checker", None)); + } + items.push(item(RightPanel::Tags, "tag", "Tags", None)); + let view = &mut app.views[index]; + let mut page_display = None; + let rail = Rail::new("rail") + .edge(Edge::End) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - // Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so - // that in a short window the buttons above scroll instead of running into it (#3282). - let view = &mut app.views[index]; - let mut page_display = None; - egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| { - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); + .show(ui, &items, app.right.as_ref(), |ui| { + // Page navigation cluster at the bottom, a panel of its own so that in a short + // window the buttons above scroll instead of running into it (#3282). + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let id = egui::Id::new("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .horizontal_align(Align::Center) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); + } + ui.add_space(6.0); + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); + } + let font = theme::regular(11.0); + let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); + if widgets::text_width(ui, &position, &font) > ui.available_width() { + position = page_count.to_string(); + } + // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. + let shown = if view.current < page_count { view.current + 1 } else { page_count }; + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); + ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); + let page_input_id = view.page_input_id(); + // Consume replacement selection once, including when the editor has lost focus. + let reselect = std::mem::take(&mut view.page_input_reselect); + // Navigation updates the idle display, never the text being edited. + if !ui.memory(|m| m.has_focus(page_input_id)) { + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } else if reselect { + // The replacement changed the label while egui retained this editor's cursor. + let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); + let end = egui::text::CCursor::new(view.page_input.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ui.ctx(), page_input_id); + } + let edit = egui::TextEdit::singleline(&mut view.page_input) + .id(page_input_id) + .desired_width(34.0) + .horizontal_align(Align::Center) + .font(theme::medium(12.0)) + .margin(vec2(2.0, 4.0)); + let r = egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(5)) + .show(ui, |ui| ui.add(edit)) + .inner + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); + if r.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // A page label first (logical page numbers, as Acrobat), then a number. + let typed = view.page_input.clone(); + view.go_to_typed(&typed, &labels); } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let id = egui::Id::new("zoom-input"); - let draft = id.with("draft"); - let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); - let edit = egui::TextEdit::singleline(&mut typed) - .id(id) - .desired_width(40.0) - .horizontal_align(Align::Center) - .font(theme::medium(11.0)) - .text_color(t.text_muted) - .frame(egui::Frame::NONE) - .margin(vec2(2.0, 2.0)); - let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); - ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); - if r.gained_focus() { - let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), id); - } - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, typed.clone())); - } else { - ui.data_mut(|d| d.remove::(draft)); - if r.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - && let Some(zoom) = DocView::parse_zoom_percent(&typed) - { - view.set_zoom(zoom); - } - } - ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); - } - } - let font = theme::regular(11.0); - let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); - if widgets::text_width(ui, &position, &font) > ui.available_width() { - position = page_count.to_string(); - } - // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. - let shown = if view.current < page_count { view.current + 1 } else { page_count }; - let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); - ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); - let page_input_id = view.page_input_id(); - // Consume replacement selection once, including when the editor has lost focus. - let reselect = std::mem::take(&mut view.page_input_reselect); - // Navigation updates the idle display, never the text being edited. - if !ui.memory(|m| m.has_focus(page_input_id)) { - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } else if reselect { - // The replacement changed the label while egui retained this editor's cursor. - let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); - let end = egui::text::CCursor::new(view.page_input.chars().count()); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); - state.store(ui.ctx(), page_input_id); - } - let edit = egui::TextEdit::singleline(&mut view.page_input) - .id(page_input_id) - .desired_width(34.0) - .horizontal_align(Align::Center) - .font(theme::medium(12.0)) - .margin(vec2(2.0, 4.0)); - let r = egui::Frame::NONE - .fill(t.field) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(5)) - .show(ui, |ui| ui.add(edit)) - .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); - if r.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { - // A page label first (logical page numbers, as Acrobat), then a number. - let typed = view.page_input.clone(); - view.go_to_typed(&typed, &labels); - } - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } - }); - }); - egui::ScrollArea::vertical().id_salt("rail-panels").show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { - let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); - let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); - if has && !selected { - let c = r.rect.right_top() + vec2(-8.0, 8.0); - ui.painter().circle_filled(c, 3.0, t.accent); - } - if r.clicked() { - app.choose_right_panel(if selected { None } else { Some(panel) }); - } - }; - rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); - rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); - rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); - rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); - rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); - rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); - rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); - if has_check { - rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); } - rail_button(ui, RightPanel::Tags, "tag", "Tags", false); }); - if let Some(id) = page_display { - app.execute(id); - } }); + match rail.request { + Some(RailRequest::Open(panel)) => app.choose_right_panel(Some(panel)), + Some(RailRequest::Close(_)) => app.choose_right_panel(None), + _ => {} + } + if let Some(id) = page_display { + app.execute(id); + } } /// What the rail shows under the page box: the page count, or, when the current page's label is diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index c0e12d5d09..a27a25ff82 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -157,10 +157,12 @@ impl PdfCraftApp { let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); match id { "ui.dock.reset" => { - if let Err(error) = crate::docking::command(self, true, &serde_json::Value::Null) { + if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { self.notify(error); } } + "view.side_panel" => self.toggle_side_panel(), + "view.tools_pane" => self.left_open = !self.left_open, "file.open" => self.open_dialog(), "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 47c5b200a3..444afd73d5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -184,7 +184,7 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { pub(crate) trait Host { fn state(&self) -> Value; fn command(&mut self, id: &str) -> Reply; - fn dock(&mut self, reset: bool, params: &Value) -> Reply; + fn panel(&mut self, reset: bool, params: &Value) -> Reply; fn commands(&self) -> Value; fn set(&mut self, key: &str, value: &str) -> Reply; fn open(&mut self, path: &str) -> Reply; @@ -286,8 +286,8 @@ impl Control { Ok(Handled::Now(Ok(state))) } "ui.commands" => Ok(Handled::Now(Ok(host.commands()))), - "ui.dock" | "ui.dock.reset" => Ok(Handled::Now(host.dock(method == "ui.dock.reset", p))), - "ui.command" if str_param("id")? == "ui.dock" => Ok(Handled::Now(host.dock(false, p.get("params").unwrap_or(p)))), + "ui.panel" | "ui.dock.reset" => Ok(Handled::Now(host.panel(method == "ui.dock.reset", p))), + "ui.command" if str_param("id")? == "ui.panel" => Ok(Handled::Now(host.panel(false, p.get("params").unwrap_or(p)))), "ui.command" => Ok(Handled::Now(host.command(str_param("id")?))), "ui.set" => { let value = match p.get("value") { @@ -647,8 +647,8 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32 } impl Host for crate::PdfCraftApp { - fn dock(&mut self, reset: bool, params: &Value) -> Reply { - crate::docking::command(self, reset, params) + fn panel(&mut self, reset: bool, params: &Value) -> Reply { + crate::side_panels::command(self, reset, params) } fn quit(&mut self, ctx: &egui::Context) -> bool { @@ -672,7 +672,7 @@ impl Host for crate::PdfCraftApp { #[cfg(target_arch = "wasm32")] let pending_opens: Vec = Vec::new(); json!({ - "docking": self.docking, + "side_panels": crate::side_panels::state(self), "documents": docs, "pending_opens": pending_opens, "active": active.map(|v| json!({ @@ -735,7 +735,7 @@ impl Host for crate::PdfCraftApp { "default_mode": self.default_mode, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, - "tools_side": if self.docking.tools_on_right() { "right" } else { "left" }, + "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, @@ -763,7 +763,7 @@ impl Host for crate::PdfCraftApp { .iter() .map(|c| json!({ "id": c.id, "label": c.label, "menu": c.menu, "enabled": self.command_enabled(c), "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))) })) .collect(); - list.push(json!({"id": "ui.dock", "label": "Arrange Panel", "enabled": true, "params": "action, or operation/panel; move: target/zone/before; float/moveFloating: rect; resizeSplit: path/size; setStackOpen: open; resizeStack: height"})); + list.push(json!({"id": "ui.panel", "label": "Side panel", "enabled": true, "params": "panel: tools or side; open: boolean; width: number (clamped to the panel's limits)"})); json!({ "commands": list }) } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs deleted file mode 100644 index 11eb732be3..0000000000 --- a/crates/ui-egui/src/docking.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! Application panel identities and persistence around the shared docking renderer. -//! Document contents remain owned by their existing view implementations. - -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; -use craft_ui::layout::{SplitAxis, SplitSize}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Panel { - Canvas, - Tools, - Inspector, -} - -impl Panel { - pub const ALL: &[Self] = &[Self::Canvas, Self::Tools, Self::Inspector]; - pub fn id(self) -> &'static str { - match self { - Self::Canvas => "canvas", - Self::Tools => "tools", - Self::Inspector => "inspector", - } - } - fn label(self) -> &'static str { - match self { - Self::Canvas => "Document", - Self::Tools => "Tools", - Self::Inspector => "Inspector", - } - } - fn default_side(self) -> Zone { - match self { - Self::Canvas => Zone::Right, - Self::Tools => Zone::Left, - Self::Inspector => Zone::Right, - } - } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } -} - -fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } -} - -fn tabs(panel: Panel) -> Node { - Node::Tabs { panels: vec![panel], active: 0 } -} - -fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { - Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(default)] -pub struct Workspace { - pub layout: Layout, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, -} - -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(272.0), - tabs(Panel::Tools), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - } - } -} - -impl Workspace { - /// Side of the Tools panel, including its saved position while closed. - pub fn tools_on_right(&self) -> bool { - let location = self - .layout - .location(&Panel::Tools) - .ok() - .or_else(|| self.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).map(|(_, location)| location.clone())); - location.is_some_and(|location| matches!(location.placement, Placement::Split(Zone::Right))) - } - - /// Place Tools at the outer left or right edge while preserving its open state. - pub fn set_tools_on_right(&mut self, right: bool) -> Result<(), String> { - let was_open = self.layout.contains(&Panel::Tools); - self.set_visible(Panel::Tools, true)?; - let anchor = if right && self.layout.contains(&Panel::Inspector) { Panel::Inspector } else { Panel::Canvas }; - self.apply(Action::Move { panel: Panel::Tools, anchor, placement: Placement::Split(if right { Zone::Right } else { Zone::Left }) })?; - if !was_open { - self.set_visible(Panel::Tools, false)?; - } - Ok(()) - } - - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } - - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) - } - - /// Whether a panel's body is exposed by its selected tab or open stack entry. - pub(crate) fn exposed(&self, panel: Panel) -> bool { - if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { - return true; - } - let mut pending: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = pending.pop() { - match node { - Node::Split { first, second, .. } => { - pending.push(first); - pending.push(second); - } - Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, - _ => {} - } - } - false - } - - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) - } - - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => { - self.set_visible(panel, true)?; - return self.apply(Action::Activate { panel }); - } - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) - } -} - -fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> { - workspace.set_visible(Panel::Tools, app.left_open)?; - workspace.set_visible(Panel::Inspector, app.right.is_some()) -} - -fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) { - for panel in std::mem::take(&mut app.pending_panel_reveals) { - if workspace.layout.contains(&panel) { - let _ = workspace.apply(Action::Activate { panel }); - } - } -} - -fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) { - app.left_open = workspace.layout.contains(&Panel::Tools); - if !workspace.layout.contains(&Panel::Inspector) { - if app.right.is_some() { - app.choose_right_panel(None); - } - } else if app.right.is_none() { - app.choose_right_panel(Some(crate::RightPanel::Comments)); - } -} - -pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result { - let mut workspace = app.docking.clone(); - if reset { - workspace = Workspace::default(); - workspace.set_visible(Panel::Inspector, app.right.is_some())?; - } else { - sync(&mut workspace, app)?; - workspace.command(params)?; - } - publish_visibility(app, &workspace); - app.docking = workspace; - Ok(json!({"docking": app.docking})) -} - -// Split paths change when a temporarily unavailable pane is pruned. Translate renderer -// paths back to the saved tree before applying geometry changes. -fn saved_split_path(saved: &Layout, rendered: &Layout, path: &[bool]) -> Option> { - let mut target = rendered.root.as_ref()?; - for second in path { - let Node::Split { first, second: other, .. } = target else { return None }; - target = if *second { other } else { first }; - } - let mut pending = vec![(saved.root.as_ref()?, Vec::new())]; - while let Some((node, original_path)) = pending.pop() { - if let Node::Split { first, second, .. } = node { - let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() }; - if projection.contains(&Panel::Inspector) { - let _ = projection.apply(Action::Close { panel: Panel::Inspector }); - } - let has_available_panel = |child: &Node| { - Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector) - }; - // An ancestor whose Inspector-only side disappeared projects to its surviving - // child. Its divider is absent, so only the child's split may own this resize. - if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) { - return Some(original_path); - } - let mut second_path = original_path.clone(); - second_path.push(true); - pending.push((second, second_path)); - let mut first_path = original_path; - first_path.push(false); - pending.push((first, first_path)); - } - } - None -} - -pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { - let t = crate::theme::Tokens::get(ui.ctx()); - let mut workspace = std::mem::take(&mut app.docking); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { - app.notify_fmt("Panel layout could not be restored: {error}", &[("error", &error)]); - workspace = Workspace::default(); - let _ = sync(&mut workspace, app); - } - let mut style = DockStyle::from_ui(ui); - style.min_pane = 96.0; - style.float_label = crate::i18n::t("Float panel").into(); - style.close_label = crate::i18n::t("Close panel").into(); - style.move_label = crate::i18n::t("Move to group").into(); - style.panels_label = crate::i18n::t("Panels").into(); - style.resize_label = crate::i18n::t("Resize panels").into(); - style.resize_window_label = crate::i18n::t("Resize panel window").into(); - style.background = t.panel; - style.tab_background = t.chrome; - style.active_background = t.selected; - style.text = t.text; - style.inactive_text = t.text_muted; - style.border = egui::Stroke::new(1.0, t.divider); - style.accent = t.accent; - style.font = crate::theme::medium(12.0); - drain_reveals(&mut workspace, app); - let mut rendered = workspace.layout.clone(); - if app.active.is_none() && rendered.contains(&Panel::Inspector) { - // Availability is a render projection: the saved group, selection and width survive Home. - let _ = rendered.apply(Action::Close { panel: Panel::Inspector }); - } - // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped - // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it - // to the other side stays a preference and a command. - let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_))); - let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) }; - let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits( - ui, - &rendered, - &style, - |panel| crate::i18n::t(panel.label()).into(), - render_permissions, - panel_limits, - |ui, panel| match panel { - Panel::Canvas => { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); - match app.active { - None if app.combine_showing() => crate::combine_ui::page(app, ui), - None => crate::home::show(app, ui), - Some(index) => match crate::compare_ui::side_by_side(app, index) { - Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui), - None => crate::canvas::document_area(app, index, ui), - }, - } - } - Panel::Tools => crate::panels::left_panel(app, ui), - Panel::Inspector => { - ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect())); - crate::panels::right_panel(app, ui); - } - }, - ); - let _ = sync(&mut workspace, app); - drain_reveals(&mut workspace, app); - publish_visibility(app, &workspace); - app.docking = workspace; - for mut action in output.actions { - if app.active.is_none() - && let Action::ResizeSplit { path, .. } = &mut action - { - let Some(saved_path) = saved_split_path(&app.docking.layout, &rendered, path) else { continue }; - *path = saved_path; - } - if let Err(error) = - serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action}))) - { - app.notify_fmt("Panel move was not applied: {error}", &[("error", &error)]); - } - } -} - -fn panel_limits(panel: &Panel) -> PanelLimits { - let (min, max_width) = match panel { - Panel::Canvas => (egui::vec2(160.0, 160.0), 1_000_000.0), - Panel::Tools => (egui::vec2(240.0, 120.0), 520.0), - Panel::Inspector => (egui::vec2(260.0, 120.0), 520.0), - }; - PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tools_side_moves_to_both_edges_and_survives_closed_restore() { - let mut workspace = Workspace::default(); - assert!(!workspace.tools_on_right()); - workspace.set_tools_on_right(true).unwrap(); - assert!(workspace.tools_on_right()); - workspace.set_visible(Panel::Tools, false).unwrap(); - assert!(workspace.tools_on_right()); - workspace.set_tools_on_right(false).unwrap(); - assert!(!workspace.tools_on_right()); - assert!(!workspace.layout.contains(&Panel::Tools)); - workspace.set_visible(Panel::Tools, true).unwrap(); - assert!(!workspace.tools_on_right()); - workspace.validate().unwrap(); - } - - #[test] - fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() { - let mut workspace = Workspace { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(272.0), - Node::Stack { - entries: vec![ - craft_ui::docking::StackEntry { panel: Panel::Tools, open: true, height: None }, - craft_ui::docking::StackEntry { panel: Panel::Inspector, open: false, height: None }, - ], - }, - tabs(Panel::Canvas), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - }; - workspace.validate().unwrap(); - assert!(workspace.exposed(Panel::Tools)); - assert!(!workspace.exposed(Panel::Inspector)); - workspace.apply(Action::Activate { panel: Panel::Inspector }).unwrap(); - assert!(workspace.exposed(Panel::Inspector)); - workspace.apply(Action::Float { panel: Panel::Tools, rect: [10.0, 20.0, 300.0, 400.0] }).unwrap(); - workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Center) }).unwrap(); - assert!(!workspace.exposed(Panel::Tools)); - assert!(workspace.exposed(Panel::Inspector)); - } - - #[test] - fn unavailable_inspector_split_resize_targets_saved_tools_split() { - let mut workspace = Workspace::default(); - // Inspector at the outer edge makes the visible Tools split move to the root. - workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Left) }).unwrap(); - let mut rendered = workspace.layout.clone(); - rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap(); - workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap(); - let mut resized = workspace.layout.clone(); - resized.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. }))); - } - - #[test] - fn unavailable_outer_inspector_does_not_capture_its_childs_resize() { - let mut workspace = Workspace { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(330.0), - split(SplitAxis::Horizontal, SplitSize::FixedFirst(272.0), tabs(Panel::Tools), tabs(Panel::Canvas)), - tabs(Panel::Inspector), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - }; - workspace.validate().unwrap(); - let mut rendered = workspace.layout.clone(); - rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap(); - assert_eq!(path, vec![false]); - workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap(); - assert!(matches!(workspace.layout.root, Some(Node::Split { size: SplitSize::FixedSecond(330.0), .. }))); - let mut resized = workspace.layout.clone(); - resized.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. }))); - } -} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 79254afa2f..5ad5425e8d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -20,7 +20,7 @@ pub fn exists(name: &str) -> bool { white_icons().contains(name) } -fn source(name: &str) -> egui::ImageSource<'static> { +pub(crate) fn source(name: &str) -> egui::ImageSource<'static> { // PdfCraft caches an unknown name under the resolved fallback name. white_icons().source(white_icons().canonical_name(name).unwrap_or("square")) } diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 49611293ac..bbc2712d85 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -77,9 +77,6 @@ impl PdfCraftApp { }; // Show/Hide: a panel already showing closes. self.right = (self.right != Some(panel)).then_some(panel); - if self.right.is_some() { - self.pending_panel_reveals.push(crate::docking::Panel::Inspector); - } } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } @@ -340,16 +337,12 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ); ui.horizontal(|ui| { ui.label(tl!("Tools panel side")); - let right = app.docking.tools_on_right(); - if ui.selectable_label(!right, tl!("Left")).clicked() - && let Err(error) = app.docking.set_tools_on_right(false) - { - app.notify(error); + let right = app.side_panels.tools_on_right(); + if ui.selectable_label(!right, tl!("Left")).clicked() { + app.side_panels.tools_side = crate::side_panels::ToolsSide::Left; } - if ui.selectable_label(right, tl!("Right")).clicked() - && let Err(error) = app.docking.set_tools_on_right(true) - { - app.notify(error); + if ui.selectable_label(right, tl!("Right")).clicked() { + app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49b7bb81b5..6aed5dd854 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,7 +42,6 @@ mod image_pick; mod tags_ui; pub use combine_grid::ThumbState as CombineThumb; mod combine_ui; -pub mod docking; pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod camera_ui; mod commands; @@ -65,6 +64,7 @@ mod measure_ui; mod ocr_ui; mod optimize_ui; mod search_ui; +pub mod side_panels; mod sign_ui; mod stamps_ui; mod standards_ui; @@ -202,7 +202,7 @@ pub enum LeftPanel { } /// Right-hand panels, opened from the rail. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum RightPanel { Comments, Bookmarks, @@ -623,8 +623,8 @@ pub struct RecentFile { } pub struct PdfCraftApp { - pub docking: docking::Workspace, - pub(crate) pending_panel_reveals: Vec, + /// Tools side and pane widths; which panes are open is `left_open` and `right`. + pub side_panels: side_panels::SidePanels, pub session: Session, pub views: Vec, pub(crate) closed_tabs: Vec, @@ -647,6 +647,8 @@ pub struct PdfCraftApp { pub left: LeftPanel, pub left_open: bool, pub right: Option, + /// The side panel F4 reopens: the one open last. + pub(crate) last_right: RightPanel, /// The right panel that was open when the user switched to Read mode, which starts with the /// panes closed; leaving Read mode brings it back unless the user opened or closed a pane in /// Read mode meanwhile. @@ -1103,10 +1105,10 @@ impl PdfCraftApp { date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, - docking: docking::Workspace::default(), - pending_panel_reveals: Vec::new(), + side_panels: Default::default(), left_open: true, right: None, + last_right: RightPanel::Comments, right_before_read: None, comments_panel_closed: false, right_panel_closed: false, @@ -2471,7 +2473,6 @@ impl PdfCraftApp { pub(crate) fn reveal_tools(&mut self) { self.left_open = true; - self.pending_panel_reveals.push(docking::Panel::Tools); } /// Enter a tool from the catalogue, Home, palette or control channel. Organize is a @@ -2580,9 +2581,6 @@ impl PdfCraftApp { if self.right.is_none() { self.right = before; } - if self.right.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); - } } } @@ -2604,17 +2602,21 @@ impl PdfCraftApp { self.right_panel_closed = true; } self.right = panel; - if panel.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); + if let Some(panel) = panel { + self.last_right = panel; } } + /// F4: closes the side panel, or reopens the one open last. + pub(crate) fn toggle_side_panel(&mut self) { + self.choose_right_panel(if self.right.is_some() { None } else { Some(self.last_right) }); + } + /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); let mut v = serde_json::json!({ - "docking": self.docking, - "docking_left_open": self.left_open, + "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, "recent": self.recent, "reopen_last_session": self.reopen_last_session, @@ -2691,14 +2693,13 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; - if let Some(saved) = v.get("docking") - && let Ok(workspace) = serde_json::from_value::(saved.clone()) - && workspace.validate().is_ok() - { - self.docking = workspace; - self.left_open = - v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools)); - self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten(); + let panels = side_panels::restore(&v); + self.side_panels = panels.panels; + if let Some(open) = panels.tools_open { + self.left_open = open; + } + if let Some(right) = v.get("docking_right") { + self.right = serde_json::from_value(right.clone()).ok().flatten().filter(|_| !panels.side_closed); } self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]); if let Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) { @@ -3062,11 +3063,13 @@ impl PdfCraftApp { _ => return Err("always-default-view must be on or off".into()), }; } - ("tools-side", _) => self.docking.set_tools_on_right(match value { - "left" => false, - "right" => true, - _ => return Err("tools-side must be left or right".into()), - })?, + ("tools-side", _) => { + self.side_panels.tools_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("tools-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; @@ -3254,12 +3257,6 @@ impl PdfCraftApp { } (other, _) => return Err(format!("unknown option {other}")), } - if key == "panel" && self.right.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); - } - if key == "left" && self.left_open { - self.pending_panel_reveals.push(docking::Panel::Tools); - } Ok(()) } @@ -3699,27 +3696,16 @@ impl eframe::App for PdfCraftApp { } chrome::tab_strip(self, ui); chrome::mode_bar(self, ui); - if self.active.is_some() { - chrome::right_rail(self, ui); - // Read mode hides the tools, not the navigation panes: the rail stays visible - // there, so its buttons (Bookmarks, Page thumbnails, ...) must open their panels. - // Outside Read mode the docking workspace draws the inspector. - if self.mode == Mode::Read && self.right.is_some() { - egui::Panel::right("read_right_panel") - .resizable(true) - .default_size(330.0) - .size_range(260.0..=520.0) - .frame(egui::Frame::NONE) - .show(ui, |ui| panels::right_panel(self, ui)); - } - } // Whichever way another tab came to the front, it shows instead of the expanded view. if self.all_tools_full && self.all_tools_tab != self.shown_tab() { self.all_tools_full = false; } + side_panels::show(self, ui); let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::Read { + if self.mode == Mode::AllTools && self.all_tools_full { + all_tools_ui::show(self, ui); + } else { match self.active { None if self.combine_showing() => combine_ui::page(self, ui), None => home::show(self, ui), @@ -3728,10 +3714,6 @@ impl eframe::App for PdfCraftApp { None => canvas::document_area(self, index, ui), }, } - } else if self.mode == Mode::AllTools && self.all_tools_full { - all_tools_ui::show(self, ui); - } else { - docking::show(self, ui); } }); self.process_pending_edits(); diff --git a/crates/ui-egui/src/side_panels.rs b/crates/ui-egui/src/side_panels.rs new file mode 100644 index 0000000000..743882e617 --- /dev/null +++ b/crates/ui-egui/src/side_panels.rs @@ -0,0 +1,522 @@ +//! The side panels around the page: the Tools pane, the rail and the open side panel +//! ([`crate::RightPanel`]), drawn with craft-ui task panes ([`craft_ui::panes`]). +//! +//! On the right, outermost first: the rail, the Tools pane when Preferences ▸ Tools panel side is +//! Right, then the side panel, then the page. Panes are docked only; their widths are resizable +//! and saved with the preferences. + +use std::ops::RangeInclusive; + +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, SidePolicy}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{Mode, PdfCraftApp}; + +/// Default width of the Tools pane. +pub const TOOLS_WIDTH: f32 = 272.0; +/// Default width of the side panel. +pub const SIDE_WIDTH: f32 = 330.0; +const TOOLS_RANGE: RangeInclusive = 240.0..=520.0; +const SIDE_RANGE: RangeInclusive = 260.0..=520.0; +/// Version of the saved `side_panels` value. +const VERSION: u64 = 1; +/// Deepest split tree the old layout migration walks; the old renderer allowed far fewer. +const MAX_DEPTH: usize = 32; + +/// The two panes [`PaneHost`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pane { + Tools, + /// The open [`crate::RightPanel`]. + Side, +} + +impl Pane { + pub fn parse(id: &str) -> Option { + match id { + "tools" => Some(Self::Tools), + "side" => Some(Self::Side), + _ => None, + } + } + + fn range(self) -> RangeInclusive { + match self { + Self::Tools => TOOLS_RANGE, + Self::Side => SIDE_RANGE, + } + } +} + +/// Preferences ▸ Tools panel side. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ToolsSide { + #[default] + Left, + Right, +} + +/// The saved side panel layout. Which side panel is open stays in `PdfCraftApp::right` and the +/// Tools pane's open state in `PdfCraftApp::left_open`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SidePanels { + /// The one setting that places the Tools pane; [`SidePanels::tools_edge`] is its only reader + /// in the layout. + pub tools_side: ToolsSide, + pub tools_width: f32, + pub side_width: f32, +} + +impl Default for SidePanels { + fn default() -> Self { + Self { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } + } +} + +/// A width from settings or automation: finite, within the pane's limits. +fn clamp_width(pane: Pane, width: f64) -> Option { + let range = pane.range(); + width.is_finite().then(|| (width as f32).clamp(*range.start(), *range.end())) +} + +/// What [`restore`] read from the saved preferences. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Restored { + pub panels: SidePanels, + /// `None`: not saved; keep the default. + pub tools_open: Option, + /// An old layout had the side panel closed. + pub side_closed: bool, +} + +impl SidePanels { + pub fn tools_on_right(&self) -> bool { + self.tools_side == ToolsSide::Right + } + + fn tools_edge(&self) -> Edge { + match self.tools_side { + ToolsSide::Left => Edge::Start, + ToolsSide::Right => Edge::End, + } + } + + pub fn width(&self, pane: Pane) -> f32 { + match pane { + Pane::Tools => self.tools_width, + Pane::Side => self.side_width, + } + } + + fn set_width(&mut self, pane: Pane, width: f32) { + let Some(width) = clamp_width(pane, f64::from(width)) else { return }; + match pane { + Pane::Tools => self.tools_width = width, + Pane::Side => self.side_width = width, + } + } + + /// The saved value (`side_panels` in the preferences). + pub fn to_json(&self, tools_open: bool) -> Value { + json!({ + "version": VERSION, + "tools_side": self.tools_side, + "tools_open": tools_open, + "tools_width": self.tools_width, + "side_width": self.side_width, + }) + } +} + +/// Reads the side panel layout from saved preferences: `side_panels`, else the old `docking` +/// value, else the defaults. Nothing in it is an error: a malformed or unknown value gives the +/// defaults. +pub fn restore(saved: &Value) -> Restored { + let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false }; + if let Some(value) = saved.get("side_panels") { + if value.get("version").and_then(Value::as_u64) != Some(VERSION) { + return defaults; + } + let mut panels = SidePanels::default(); + if let Some(side) = value.get("tools_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + panels.tools_side = side; + } + if let Some(width) = value.get("tools_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Tools, w)) { + panels.tools_width = width; + } + if let Some(width) = value.get("side_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Side, w)) { + panels.side_width = width; + } + return Restored { panels, tools_open: value.get("tools_open").and_then(Value::as_bool), side_closed: false }; + } + let Some(mut restored) = saved.get("docking").and_then(migrate) else { return defaults }; + if let Some(open) = saved.get("docking_left_open").and_then(Value::as_bool) { + restored.tools_open = Some(open); + } + restored +} + +/// Where the old docking tree held a panel. +struct Found { + /// The panel's group held only it: a tab group or stack of its own. + alone: bool, + /// The innermost split around the panel's own group. + split: Option, +} + +struct Split { + horizontal: bool, + /// The panel is in the split's second (right or bottom) child. + second: bool, + /// The split's fixed size, when it is the panel's side. + size: Option, +} + +/// The pixel size of `size` (`{"FixedFirst": w}` or `{"FixedSecond": w}`) when it fixes the side +/// the panel is on. +fn fixed_size(size: Option<&Value>, second: bool) -> Option { + let key = if second { "FixedSecond" } else { "FixedFirst" }; + size?.get(key)?.as_f64() +} + +fn group_ids(node: &Value) -> Option> { + if let Some(tabs) = node.get("Tabs") { + return Some(tabs.get("panels")?.as_array()?.iter().filter_map(Value::as_str).collect()); + } + let entries = node.get("Stack")?.get("entries")?.as_array()?; + Some(entries.iter().filter_map(|entry| entry.get("panel")?.as_str()).collect()) +} + +fn find(node: &Value, panel: &str, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + if let Some(ids) = group_ids(node) { + return ids.contains(&panel).then(|| Found { alone: ids.iter().all(|id| *id == panel), split: None }); + } + let split = node.get("Split")?; + for (key, second) in [("first", false), ("second", true)] { + if let Some(mut found) = split.get(key).and_then(|child| find(child, panel, depth + 1)) { + // A panel sharing its group was placed by its group, not by a split. + if found.alone && found.split.is_none() { + let horizontal = split.get("axis").and_then(Value::as_str) == Some("Horizontal"); + found.split = Some(Split { horizontal, second, size: fixed_size(split.get("size"), second) }); + } + return Some(found); + } + } + None +} + +/// The old layout's saved place of a closed panel: whether it was on the right and its width. +fn hidden_place<'a>(old: &'a Value, panel: &str) -> Option<&'a Value> { + old.get("hidden")?.as_array()?.iter().find_map(|entry| { + let pair = entry.as_array()?; + (pair.first()?.as_str()? == panel).then(|| pair.get(1)).flatten() + }) +} + +fn floating_contains(old: &Value, panel: &str) -> bool { + old.get("layout").and_then(|l| l.get("floating")).and_then(Value::as_array).is_some_and(|groups| { + groups.iter().any(|group| group.get("panels").and_then(Value::as_array).is_some_and(|ids| ids.iter().any(|id| id.as_str() == Some(panel)))) + }) +} + +/// A panel of the old layout: on the right edge, open, and its width where the layout stated it +/// in points. A floating or tabbed panel counts as on the left, as the old Tools side reading did. +fn old_panel(old: &Value, panel: &str) -> (bool, bool, Option) { + let root = old.get("layout").and_then(|l| l.get("root")); + if let Some(found) = root.and_then(|root| find(root, panel, 0)) { + return match found.split { + Some(split) if split.horizontal => (split.second, true, split.size), + _ => (false, true, None), + }; + } + if floating_contains(old, panel) { + return (false, true, None); + } + let Some(place) = hidden_place(old, panel) else { return (false, false, None) }; + let right = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Right"); + let left = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Left"); + let size = if right || left { fixed_size(place.get("size"), right) } else { None }; + (right, false, size) +} + +/// Reads the old docking layout (craft-ui's `Workspace`) as plain JSON. `None` when it has no +/// document view, which the old layout always held. +fn migrate(old: &Value) -> Option { + find(old.get("layout")?.get("root")?, "canvas", 0)?; + let mut panels = SidePanels::default(); + let (tools_right, tools_open, tools_width) = old_panel(old, "tools"); + let (_, side_open, side_width) = old_panel(old, "inspector"); + if tools_right { + panels.tools_side = ToolsSide::Right; + } + if let Some(width) = tools_width.and_then(|w| clamp_width(Pane::Tools, w)) { + panels.tools_width = width; + } + if let Some(width) = side_width.and_then(|w| clamp_width(Pane::Side, w)) { + panels.side_width = width; + } + Some(Restored { panels, tools_open: Some(tools_open), side_closed: !side_open }) +} + +/// What `ui.panel` and `ui.state` report. +pub fn state(app: &PdfCraftApp) -> Value { + let panels = &app.side_panels; + json!({ + "tools_side": panels.tools_side, + "tools_open": app.left_open, + "tools_width": panels.tools_width, + "side_open": app.right.is_some(), + "side_panel": app.right.map(|r| format!("{r:?}")), + "side_width": panels.side_width, + }) +} + +/// `ui.panel {panel, open?, width?}` and `ui.dock.reset` (`reset`). +pub fn command(app: &mut PdfCraftApp, reset: bool, params: &Value) -> Result { + if reset { + app.side_panels = SidePanels::default(); + app.left_open = true; + return Ok(json!({ "side_panels": state(app) })); + } + let pane = Pane::parse(params.get("panel").and_then(Value::as_str).ok_or("panel must be \"tools\" or \"side\"")?) + .ok_or("panel must be \"tools\" or \"side\"")?; + let open = match params.get("open") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_bool().ok_or("open must be a boolean")?), + }; + let width = match params.get("width") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_f64().and_then(|w| clamp_width(pane, w)).ok_or("width must be a finite number")?), + }; + if let Some(width) = width { + app.side_panels.set_width(pane, width); + } + match (pane, open) { + (Pane::Tools, Some(open)) => app.left_open = open, + (Pane::Side, Some(true)) if app.right.is_none() => app.toggle_side_panel(), + (Pane::Side, Some(false)) if app.right.is_some() => app.choose_right_panel(None), + _ => {} + } + Ok(json!({ "side_panels": state(app) })) +} + +struct Content<'a> { + app: &'a mut PdfCraftApp, +} + +impl PaneContent for Content<'_> { + fn title(&self, pane: &Pane) -> String { + crate::i18n::t(match pane { + Pane::Tools => "Tools", + Pane::Side => "Inspector", + }) + .to_owned() + } + + fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { + match pane { + Pane::Tools => crate::panels::left_panel(self.app, ui), + Pane::Side => { + ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect())); + crate::panels::right_panel(self.app, ui); + } + } + } + + fn default_state(&self, pane: &Pane) -> PaneState { + state_of(&self.app.side_panels, *pane) + } + + fn size_range(&self, pane: &Pane) -> RangeInclusive { + pane.range() + } + + fn movable(&self, _pane: &Pane) -> bool { + false + } + + fn title_bar(&self, _pane: &Pane) -> bool { + // Both panels draw their own header and Close. + false + } +} + +fn state_of(panels: &SidePanels, pane: Pane) -> PaneState { + let edge = match pane { + Pane::Tools => panels.tools_edge(), + Pane::Side => Edge::End, + }; + PaneState::docked(edge, panels.width(pane)) +} + +/// The panes that show this frame, outermost first on a shared edge. +fn open_panes(app: &PdfCraftApp) -> Vec { + let mut open = Vec::new(); + // Read mode hides the tools, not the navigation panes. + if app.left_open && app.mode != Mode::Read { + open.push(Pane::Tools); + } + // Home and Combine files have no side panel; the choice is kept for the next document. + if app.active.is_some() && app.right.is_some() { + open.push(Pane::Side); + } + open +} + +/// Draws the rail and the open panes. Call after the top bars and before the central panel. +pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + if let Some(panel) = app.right { + app.last_right = panel; + } + // The rail first: it is the outermost panel on the right. + if app.active.is_some() { + crate::chrome::right_rail(app, ui); + } + // The expanded All tools view covers the panes. + if app.mode == Mode::AllTools && app.all_tools_full { + return; + } + let open = open_panes(app); + if open.is_empty() { + return; + } + let t = crate::theme::Tokens::get(ui.ctx()); + let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; + let panels = app.side_panels; + // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. + let output = PaneHost::new("pdfcraft-side-panels") + .policy(SidePolicy::Columns) + .f6(false) + .labels(labels) + .frame(egui::Frame::NONE.fill(t.panel)) + .body_margin(egui::Margin::ZERO) + .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app }); + for request in output.requests { + match request { + PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width), + PaneRequest::Close(Pane::Tools) => app.left_open = false, + PaneRequest::Close(Pane::Side) => app.choose_right_panel(None), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tabs(panels: &[&str]) -> Value { + json!({"Tabs": {"panels": panels, "active": 0}}) + } + + fn split(size: Value, first: Value, second: Value) -> Value { + json!({"Split": {"axis": "Horizontal", "size": size, "first": first, "second": second}}) + } + + fn workspace(root: Value, floating: Value, hidden: Value) -> Value { + json!({"docking": {"layout": {"root": root, "floating": floating}, "hidden": hidden}}) + } + + /// The old default layout, as craft-ui 5c874d5 serialized it. + fn docked(tools: f64, inspector: f64) -> Value { + workspace( + split(json!({"FixedFirst": tools}), tabs(&["tools"]), split(json!({"FixedSecond": inspector}), tabs(&["canvas"]), tabs(&["inspector"]))), + json!([]), + json!([]), + ) + } + + #[test] + fn docked_default_keeps_widths() { + let restored = restore(&docked(300.0, 400.0)); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 300.0, side_width: 400.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn tools_on_the_right_moves_right() { + // `set_tools_on_right` put Tools to the right of the Inspector. + let old = workspace( + split(json!({"FixedSecond": 330.0}), tabs(&["canvas"]), split(json!({"FixedSecond": 290.0}), tabs(&["inspector"]), tabs(&["tools"]))), + json!([]), + json!([]), + ); + let restored = restore(&old); + assert_eq!(restored.panels.tools_side, ToolsSide::Right); + assert_eq!(restored.panels.tools_width, 290.0); + // The Inspector's own split fixes Tools' width, not its. + assert_eq!(restored.panels.side_width, SIDE_WIDTH); + } + + #[test] + fn floated_tools_docks_left_and_stays_open() { + let old = workspace( + split(json!({"FixedSecond": 360.0}), tabs(&["canvas"]), tabs(&["inspector"])), + json!([{"panels": ["tools"], "active": 0, "rect": [10.0, 20.0, 300.0, 400.0]}]), + json!([]), + ); + let restored = restore(&old); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: 360.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn tools_tabbed_with_the_inspector_docks_both() { + let old = workspace(split(json!({"FixedSecond": 350.0}), tabs(&["canvas"]), tabs(&["inspector", "tools"])), json!([]), json!([])); + let restored = restore(&old); + assert_eq!(restored.panels.tools_side, ToolsSide::Left); + assert_eq!((restored.panels.tools_width, restored.panels.side_width), (TOOLS_WIDTH, SIDE_WIDTH)); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn a_hidden_inspector_stays_closed_and_hidden_tools_keeps_its_side() { + let old = workspace( + tabs(&["canvas"]), + json!([]), + json!([ + ["inspector", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 380.0}, "stack": null, "sibling": ["canvas"]}], + ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 250.0}, "stack": null, "sibling": ["canvas"]}] + ]), + ); + let restored = restore(&old); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); + } + + #[test] + fn malformed_values_give_the_defaults() { + let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false }; + for saved in [ + json!({"docking": "not a layout"}), + json!({"docking": {"layout": [1, 2]}}), + json!({"docking": {"layout": {"root": {"Split": {"axis": 3, "size": {"FixedFirst": "wide"}, "first": null}}}}}), + json!({"side_panels": {"version": 2, "tools_side": "right"}}), + json!({"side_panels": "right"}), + ] { + let restored = restore(&saved); + assert_eq!(restored, defaults, "{saved}"); + } + // Non-finite and out-of-range widths fall back or clamp; an unknown side keeps Left. + let restored = restore(&json!({"side_panels": {"version": 1, "tools_side": "up", "tools_width": 9000.0, "side_width": "x"}})); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 520.0, side_width: SIDE_WIDTH }); + // Deep trees are cut off rather than walked. + let mut deep = tabs(&["tools"]); + for _ in 0..200 { + deep = split(json!({"Ratio": 0.5}), tabs(&["canvas"]), deep); + } + assert_eq!(restore(&workspace(deep, json!([]), json!([]))).panels.tools_side, ToolsSide::Left); + } + + #[test] + fn saved_value_round_trips() { + let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 }; + let restored = restore(&json!({ "side_panels": panels.to_json(false) })); + assert_eq!(restored, Restored { panels, tools_open: Some(false), side_closed: false }); + } +} diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 7099ece40b..3ad7953b33 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use egui_kittest::Harness; -use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::kittest::Queryable; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; use pdfcraft_ui_egui::control::{ControlClient, Reply}; @@ -420,16 +420,16 @@ fn lithuanian_inspector_title_updates_through_the_control_channel() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); + h.get_by_label("Inspector"); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius"); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); + h.get_by_label("Inspektorius"); + assert!(h.query_by_label("Inspector").is_none()); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); + h.get_by_label("Inspector"); } /// Switching the interface language changes labels only: documents, their dirty state and the @@ -1497,135 +1497,72 @@ fn a_plan_scale_picked_or_typed_applies_at_once() { } #[test] -fn docking_pointer_group_float_close_restore_and_document_commands() { - use pdfcraft_ui_egui::docking::Panel; +fn ui_panel_opens_closes_and_sizes_panes_and_survives_restore() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command. - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none()); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - h.run_steps(4); - assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools)); - // Grouped, Tools has its tab again. - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools"); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); h.run_steps(3); - h.get_by_label("Float panel").click(); - h.run_steps(5); - let rect = h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect; - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); + let state = ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 401.6})); + assert_eq!(state["side_panels"]["side_width"], 401.6f32 as f64); + // Widths are clamped to the panel's limits. + let state = ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 9000})); + assert_eq!(state["side_panels"]["tools_width"], 520.0); + ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 300, "open": false})); h.run_steps(3); - // Scope to the open context menu; the Tools body also has a close icon. - h.get_by_label("Float panel").parent().unwrap().get_by_label("Close panel").click(); - h.run_steps(4); - assert!(!h.state().docking.layout.contains(&Panel::Inspector)); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - assert_eq!(h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect, rect); + assert!(!h.state().left_open); + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": false})); + h.run_steps(3); + assert_eq!(h.state().right, None); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); + // Reopening the side panel brings back the one open last. + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": true})); + h.run_steps(3); + h.get_by_label("This document has no bookmarks."); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"]["side_panel"], "Bookmarks"); let saved = h.state().persist(); - let expected = serde_json::to_value(&h.state().docking).unwrap(); let (mut reloaded, client) = harness(); reloaded.state_mut().restore(&saved); - reloaded.run_steps(5); - assert_eq!(serde_json::to_value(&reloaded.state().docking).unwrap(), expected); - ok(&mut reloaded, &client, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); reloaded.run_steps(4); - ok(&mut reloaded, &client, "ui.command", json!({"id":"page.insert_blank"})); - assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 6); - ok(&mut reloaded, &client, "ui.command", json!({"id":"edit.undo"})); + let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone(); + assert_eq!( + state, + json!({"tools_side":"left", "tools_open":false, "tools_width":300.0, "side_open":true, "side_panel":"Bookmarks", "side_width":401.6f32 as f64}) + ); + // The ui.command form and the reset command. + ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.panel", "params":{"panel":"tools", "open":true}})); + assert!(reloaded.state().left_open); + ok(&mut reloaded, &client, "ui.panel", json!({"panel":"tools", "width": 300})); + ok(&mut reloaded, &client, "ui.set", json!({"key":"tools-side", "value":"right"})); + ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.dock.reset"})); + let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone(); + assert_eq!((state["tools_side"].clone(), state["tools_width"].clone(), state["side_width"].clone()), (json!("left"), json!(272.0), json!(330.0))); assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 5); } #[test] -fn invalid_docking_requests_are_atomic_and_do_not_change_documents() { +fn invalid_panel_requests_change_nothing() { let (mut h, c) = harness(); - let before = serde_json::to_value(&h.state().docking).unwrap(); + let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone(); for params in [ - json!({"operation":"close", "panel":"canvas"}), - json!({"operation":"move", "panel":"tools", "target":"canvas", "zone":"center"}), - json!({"operation":"float", "panel":"tools", "rect":[0,0,-1,100]}), - json!({"operation":"move", "panel":"unknown", "target":"tools", "zone":"center"}), + json!({"panel":"canvas"}), + json!({"panel":"inspector", "open": true}), + json!({}), + json!({"panel":"tools", "open":"yes"}), + json!({"panel":"tools", "width":"wide"}), + json!({"panel":"side", "width": 300, "open": 1}), ] { - assert!(call(&mut h, &c, "ui.dock", params).is_err()); - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert!(call(&mut h, &c, "ui.panel", params).is_err()); let state = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(state["side_panels"], before); assert_eq!(state["documents"][0]["pages"], 5); assert_eq!(state["documents"][0]["dirty"], false); } } +/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown. #[test] -fn docking_renders_real_pdf_default_floating_and_redocked() { - for width in [900.0, 1400.0] { - for scale in [1.0, 2.0] { - let (mut h, c) = harness_pages_at(5, width, scale); - ok(&mut h, &c, "ui.resize", json!({"width":width,"height":900})); - ok(&mut h, &c, "ui.set", json!({"key":"panel","value":"bookmarks"})); - h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { - parent: vec![], - index: 0, - title: "Overview".into(), - page: 0, - left: None, - top: None, - }); - h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { - parent: vec![], - index: 1, - title: "Details".into(), - page: 2, - left: None, - top: None, - }); - for state in ["default", "floating", "redocked"] { - if state == "floating" { - ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[160,100,330,480]})); - } else if state == "redocked" { - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - } - h.run_steps(5); - h.state().docking.validate().unwrap(); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - std::fs::create_dir_all(&directory).unwrap(); - h.render().unwrap().save(format!("{directory}/pdf-{width}-{scale}-{state}.png")).unwrap(); - } - } - } - } -} - -#[test] -fn explicit_panel_requests_reveal_inactive_group_members() { - let (mut h, c) = harness(); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); - ok(&mut h, &c, "ui.command", json!({"id":"form.fields"})); - h.run_steps(4); - h.get_by_label("Fields"); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click(); - h.run_steps(4); - // Ordinary frames must preserve the user's selected tab. - h.get_by_label("View more"); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - h.get_by_label("This document has no bookmarks."); - ok(&mut h, &c, "ui.command", json!({"id":"edit.edit_text"})); - h.run_steps(4); - // The Edit panel's own row: the tool's name is on the floating toolbar too while it is on. - h.get_by_label("Header and footer"); -} - -/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even -/// when the inspector is a background tab of a custom docking layout. -#[test] -fn a_script_opened_panel_reveals_an_inactive_inspector() { +fn a_script_opened_panel_shows() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); h.run_steps(3); assert!(h.query_by_label("This document has no bookmarks.").is_none()); let id = h.state().active_ids().unwrap().1; @@ -1635,12 +1572,11 @@ fn a_script_opened_panel_reveals_an_inactive_inspector() { } #[test] -fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { - use pdfcraft_ui_egui::docking::Panel; +fn home_and_combine_hide_the_side_panel_without_forgetting_it() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[170,120,330,470]})); - let before = serde_json::to_value(&h.state().docking).unwrap(); + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 380})); + let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone(); for combine in [false, true] { if combine { h.state_mut().open_combine_tab(); @@ -1648,62 +1584,41 @@ fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { h.state_mut().active = None; } h.run_steps(4); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - std::fs::create_dir_all(&directory).unwrap(); - let state = if combine { "combine" } else { "home" }; - h.render().unwrap().save(format!("{directory}/availability-{state}.png")).unwrap(); - } - assert!(h.state().docking.layout.contains(&Panel::Inspector)); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], before); h.state_mut().active = Some(0); h.state_mut().combine_tab.focused = false; h.run_steps(4); h.get_by_label("This document has no bookmarks."); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap(); - } - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], before); } } #[test] -fn malformed_docking_settings_preserve_other_preferences() { +fn malformed_panel_settings_preserve_other_preferences() { let mut app = PdfCraftApp::new(); app.restore(r#"{"docking":{"layout":{"root":null,"floating":[]},"hidden":[]},"default_mode":"edit","flatten_fill_sign":true}"#); assert_eq!(app.default_mode, pdfcraft_ui_egui::Mode::Edit); - app.docking.validate().unwrap(); + assert_eq!(app.side_panels, pdfcraft_ui_egui::side_panels::SidePanels::default()); let saved: Value = serde_json::from_str(&app.persist()).unwrap(); assert_eq!(saved["default_mode"], "edit"); assert_eq!(saved["flatten_fill_sign"], true); + // The old value is dropped on the next save. + assert!(saved.get("docking").is_none()); + assert_eq!(saved["side_panels"]["version"], 1); } #[test] -fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel() { - use pdfcraft_ui_egui::docking::Panel; +fn rail_opens_a_panel_then_closes_the_open_one() { let (mut h, c) = harness(); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); - h.run_steps(3); let document = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); - let mut preferences: Value = serde_json::from_str(&h.state().persist()).unwrap(); - preferences.as_object_mut().unwrap().remove("docking"); - assert!(h.query_by_label("This document has no bookmarks.").is_none()); h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click(); h.run_steps(4); h.get_by_label("This document has no bookmarks."); - let mut after: Value = serde_json::from_str(&h.state().persist()).unwrap(); - after.as_object_mut().unwrap().remove("docking"); - assert_eq!(after, preferences, "revealing a group member only changes layout activation"); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document); h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click(); h.run_steps(4); assert_eq!(h.state().right, None); - assert!(!h.state().docking.layout.contains(&Panel::Inspector)); assert!(h.query_by_label("This document has no bookmarks.").is_none()); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 12fc0cd92e..a5c3192358 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod side_panels; mod signing; mod stamps; mod text_select; diff --git a/crates/ui-egui/tests/it/panel_snapshots.rs b/crates/ui-egui/tests/it/panel_snapshots.rs index c9cc1d5901..97fba87777 100644 --- a/crates/ui-egui/tests/it/panel_snapshots.rs +++ b/crates/ui-egui/tests/it/panel_snapshots.rs @@ -87,12 +87,12 @@ fn harness(panel: &'static str) -> Harness<'static, PdfCraftApp> { h } -/// The side panel's contents: the dock node after the Inspector tab. +/// The side panel's contents: the node after the pane's own focus stop, named "Inspector". fn panel<'h>(h: &'h Harness<'static, PdfCraftApp>) -> Node<'h> { - let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")); - let dock = tab.parent().expect("the Inspector tab sits in a dock node"); - let mut after = dock.children().skip_while(|n| n.accesskit_node().id() != tab.accesskit_node().id()).skip(1); - after.next().expect("the panel follows its tab") + let stop = h.get_by(|n| n.role() != Role::Label && n.label().as_deref() == Some("Inspector")); + let pane = stop.parent().expect("the side panel's focus stop sits in its pane"); + let mut after = pane.children().skip_while(|n| n.accesskit_node().id() != stop.accesskit_node().id()).skip(1); + after.next().expect("the panel follows its focus stop") } /// One line per node that says something: layout containers are skipped (their children move up a diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 145446cc13..78a8b0b3b4 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -339,10 +339,10 @@ fn link_tip_stays_off_while_the_palette_is_open() { h.get_by_label("https://example.org/"); } -/// A panel's tab carried over a link shows neither its tip nor its cursor, and the link answers -/// again once the tab is let go. +/// A panel's resize edge dragged over a link shows neither the link's tip nor its cursor, and the +/// link answers again once the edge is let go. #[test] -fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() { +fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() { use egui::accesskit::Role; use egui_kittest::kittest::Queryable; let mut h = open(link_pdf(), "150", false); @@ -354,10 +354,17 @@ fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() { assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); h.run_steps(2); assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); - // Away from the link, then the Inspector's tab is picked up and carried over it. + // Away from the link, then the side panel's resize edge (the rightmost one) is picked up and + // carried over it. h.hover_at(at(&h, 150.0, 100.0)); h.run_steps(4); - let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center(); + let tab = h + .get_all_by(|n| n.role() == Role::Splitter && n.label().as_deref() == Some("Resize panels")) + .map(|n| n.rect().center()) + .max_by(|a, b| a.x.total_cmp(&b.x)) + .expect("the side panel has a resize edge") + // The pane's half of the edge: the page, drawn after the panes, takes the other half. + + egui::vec2(2.0, 0.0); h.hover_at(tab); h.run_steps(1); h.drag_at(tab); @@ -368,11 +375,11 @@ fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() { } for _ in 0..4 { h.run_steps(2); - assert!(h.ctx.dragged_id().is_some(), "the tab is still being dragged"); - assert!(!tip_shown(&h), "no link tip under a dragged panel tab"); + assert!(h.ctx.dragged_id().is_some(), "the edge is still being dragged"); + assert!(!tip_shown(&h), "no link tip under a dragged panel edge"); assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); } - // Back to the tab and let go there, which leaves the layout as it was. + // Back to the start and let go there, which leaves the width as it was. for i in 1..=8 { h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs new file mode 100644 index 0000000000..2436d41b61 --- /dev/null +++ b/crates/ui-egui/tests/it/side_panels.rs @@ -0,0 +1,133 @@ +//! Side panels: the Tools pane, the rail and the open side panel. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +const WIDTH: f32 = 1400.0; +const NO_BOOKMARKS: &str = "This document has no bookmarks."; + +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(WIDTH, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + setup(&mut app); + app + }); + h.run_steps(6); + h +} + +fn rail(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_role_and_label(Role::Button, "Bookmarks").rect() +} + +fn side(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_label(NO_BOOKMARKS).rect() +} + +/// The Tools pane's All tools header button. +fn tools(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_label("Expand all tools").rect() +} + +#[test] +fn rail_is_outermost_then_tools_then_the_side_panel_then_the_page() { + // Tools on the left: the page between Tools and the side panel, the rail at the right edge. + let h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + let (r, s, t) = (rail(&h), side(&h), tools(&h)); + assert!(r.right() > WIDTH - 12.0, "{r:?}"); + assert!(s.right() < r.left() && s.left() > WIDTH - 48.0 - 340.0, "{s:?} {r:?}"); + assert!(t.left() < 48.0, "{t:?}"); + // Tools on the right: rail, Tools, side panel, page. + let h = harness(|app| { + app.set_option("tools-side", "right").unwrap(); + app.set_option("panel", "bookmarks").unwrap(); + }); + let (r, s, t) = (rail(&h), side(&h), tools(&h)); + assert!(r.right() > WIDTH - 12.0, "{r:?}"); + assert!(t.right() < r.left() && t.left() > WIDTH - 48.0 - 280.0, "{t:?} {r:?}"); + assert!(s.right() < t.left() && s.left() > WIDTH - 48.0 - 280.0 - 340.0, "{s:?} {t:?}"); +} + +#[test] +fn read_mode_shows_the_side_panel_without_tools() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(4); + assert!(h.query_by_label("Expand all tools").is_none()); + assert!(side(&h).right() < rail(&h).left() && side(&h).left() > WIDTH / 2.0); + // The rail closes and opens it there too. + h.get_by_role_and_label(Role::Button, "Bookmarks").click(); + h.run_steps(4); + assert!(h.query_by_label(NO_BOOKMARKS).is_none()); + h.get_by_role_and_label(Role::Button, "Bookmarks").click(); + h.run_steps(4); + assert!(side(&h).right() < rail(&h).left()); +} + +#[test] +fn a_dragged_width_is_remembered_across_restarts() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + let before = h.state().side_panels.side_width; + let side_left = side(&h).left(); + // The side panel's resize edge: the one right of the page, left of the panel's text. + let edge = h + .get_all_by_label("Resize panels") + .map(|n| n.rect().center()) + .filter(|c| c.x < side_left && c.x > WIDTH / 2.0) + .min_by(|a, b| (side_left - a.x).total_cmp(&(side_left - b.x))) + .expect("the side panel has a resize edge") + // The pane's half of the edge: the page, drawn after the panes, takes the other half. + + egui::vec2(2.0, 0.0); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(edge - egui::vec2(10.0 * i as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(edge - egui::vec2(40.0, 0.0)); + h.run_steps(3); + let after = h.state().side_panels.side_width; + // The pointer was pressed 2 pt inside the panel, and the edge follows the pointer. + assert!((after - (before + 40.0 - 2.0)).abs() <= 1.0, "{before} -> {after}"); + let saved = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert_eq!(restored.side_panels.side_width, after); +} + +#[test] +fn f4_toggles_the_side_panel_and_shift_f4_the_tools_panel() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + h.get_by_label(NO_BOOKMARKS); + h.key_press(egui::Key::F4); + h.run_steps(3); + assert_eq!(h.state().right, None); + assert!(h.query_by_label(NO_BOOKMARKS).is_none()); + // F4 again reopens the panel that was open, not the default. + h.key_press(egui::Key::F4); + h.run_steps(3); + h.get_by_label(NO_BOOKMARKS); + assert!(h.state().left_open); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4); + h.run_steps(3); + assert!(!h.state().left_open); + assert!(h.query_by_label("Expand all tools").is_none()); + // Shift+F4 leaves the side panel alone. + h.get_by_label(NO_BOOKMARKS); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4); + h.run_steps(3); + h.get_by_label("Expand all tools"); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 7582b6c4dc..a047e916ee 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -49,14 +49,14 @@ fn tools_panel_side_preference_moves_and_persists() { h.get_by_label("Tools panel side"); h.get_by_label("Right").click(); h.run_steps(3); - assert!(h.state().docking.tools_on_right()); + assert!(h.state().side_panels.tools_on_right()); let saved = h.state().persist(); let mut restored = PdfCraftApp::new(); restored.restore(&saved); - assert!(restored.docking.tools_on_right()); + assert!(restored.side_panels.tools_on_right()); h.get_by_label("Left").click(); h.run_steps(3); - assert!(!h.state().docking.tools_on_right()); + assert!(!h.state().side_panels.tools_on_right()); } /// Every piece of text painted on the last frame: its whole text, whether it was cut with "…", From e90f7882f19c4c2d8ac8037bc4fcb43c70478386 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 12:30:31 -0600 Subject: [PATCH 05/33] Migrate PdfCraft to egui 0.37 (#4591) * Record successful PdfCraft web application check * E21: bound startup requests and validate font and license inputs * E21: separate pending control completion and gesture regression phases * M-egui037: enforce control admission and per-family font notices * M-egui037: qualify capture fixes and document migration behavior * egui-037: identify current and historical qualification results --- .github/workflows/ci.yml | 5 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 13 +- ATTRIBUTION.toml | 32 +- Cargo.lock | 294 ++- Cargo.toml | 38 +- NOTICE | 18 + ROADMAP.md | 10 +- apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft/Cargo.toml | 4 +- apps/pdfcraft/src/main.rs | 119 +- apps/pdfcraft/tests/it/main.rs | 1 + apps/pdfcraft/tests/it/vendor_graph.rs | 81 + .../tests/it/winit_macos_emoji_picker.rs | 8 +- .../tests/it/winit_macos_ime_commit.rs | 8 +- .../tests/it/winit_macos_ime_discard.rs | 7 +- .../tests/it/winit_macos_korean_first_key.rs | 7 +- .../tests/it/winit_macos_korean_space.rs | 7 +- .../tests/it/winit_macos_title_bar_drag.rs | 8 +- .../pdfcraft/tests/it/winit_wayland_scroll.rs | 24 +- .../tests/it/winit_wayland_text_input.rs | 7 +- .../it/winit_windows_hidden_maximized.rs | 7 +- .../tests/it/winit_windows_ime_cursor.rs | 7 +- apps/pdfcraft/tests/it/winit_x11_dpi.rs | 6 +- apps/pdfcraft/tests/it/winit_x11_ime_focus.rs | 7 +- crates/fonts/build.rs | 111 +- crates/fonts/src/cjk_scripts.rs | 78 + crates/fonts/src/craft.rs | 10 +- crates/fonts/tests/build_input.rs | 4 + crates/print/src/tests.rs | 29 +- crates/redact/src/tests.rs | 68 + crates/ui-egui/Cargo.toml | 17 +- crates/ui-egui/src/actions_ui.rs | 14 +- crates/ui-egui/src/all_tools_ui.rs | 21 +- crates/ui-egui/src/appearance.rs | 6 +- crates/ui-egui/src/bidi.rs | 234 +- crates/ui-egui/src/bulk_fields.rs | 2 +- crates/ui-egui/src/calc_order_ui.rs | 7 +- crates/ui-egui/src/camera_ui.rs | 4 +- crates/ui-egui/src/canvas.rs | 733 +++++- crates/ui-egui/src/chrome.rs | 59 +- crates/ui-egui/src/combine_grid.rs | 40 +- crates/ui-egui/src/combine_ui.rs | 23 +- crates/ui-egui/src/comments.rs | 23 +- crates/ui-egui/src/comments_panel.rs | 33 +- crates/ui-egui/src/content_ui.rs | 14 +- crates/ui-egui/src/control.rs | 1998 ++++++++++++++++- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 94 +- crates/ui-egui/src/edit_text_ui.rs | 19 +- crates/ui-egui/src/field_table_ui.rs | 15 +- crates/ui-egui/src/fill_sign.rs | 28 +- crates/ui-egui/src/fling.rs | 28 +- crates/ui-egui/src/folders_ui.rs | 13 +- crates/ui-egui/src/format_text_ui.rs | 4 +- crates/ui-egui/src/forms_ui.rs | 32 +- crates/ui-egui/src/home.rs | 17 +- crates/ui-egui/src/i18n/ar.tsv | 15 +- crates/ui-egui/src/i18n/bg.tsv | 9 + crates/ui-egui/src/i18n/ca.tsv | 9 + crates/ui-egui/src/i18n/catalog.rs | 9 - crates/ui-egui/src/i18n/ckb.tsv | 15 +- crates/ui-egui/src/i18n/cs.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 9 + crates/ui-egui/src/i18n/el.tsv | 9 + crates/ui-egui/src/i18n/es.tsv | 9 + crates/ui-egui/src/i18n/fa.tsv | 6 +- crates/ui-egui/src/i18n/fi.tsv | 9 + crates/ui-egui/src/i18n/fr.tsv | 9 + crates/ui-egui/src/i18n/hi.tsv | 9 + crates/ui-egui/src/i18n/hu.tsv | 9 + crates/ui-egui/src/i18n/id.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 9 + crates/ui-egui/src/i18n/ja.tsv | 9 + crates/ui-egui/src/i18n/kk.tsv | 9 + crates/ui-egui/src/i18n/ko.tsv | 9 + crates/ui-egui/src/i18n/lo.tsv | 9 + crates/ui-egui/src/i18n/lt.tsv | 9 + crates/ui-egui/src/i18n/mod.rs | 126 +- crates/ui-egui/src/i18n/nb.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 9 + crates/ui-egui/src/i18n/pt-br.tsv | 9 + crates/ui-egui/src/i18n/ro.tsv | 9 + crates/ui-egui/src/i18n/ru.tsv | 9 + crates/ui-egui/src/i18n/sl.tsv | 9 + crates/ui-egui/src/i18n/sq.tsv | 9 + crates/ui-egui/src/i18n/te.tsv | 9 + crates/ui-egui/src/i18n/th.tsv | 9 + crates/ui-egui/src/i18n/tr.tsv | 9 + crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/i18n/vi.tsv | 9 + crates/ui-egui/src/i18n/zh-hans.tsv | 9 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 + crates/ui-egui/src/js_ui.rs | 185 +- crates/ui-egui/src/lib.rs | 167 +- crates/ui-egui/src/link_ui.rs | 2 +- crates/ui-egui/src/measure_ui.rs | 15 +- crates/ui-egui/src/motion.rs | 144 +- crates/ui-egui/src/object_ui.rs | 6 + crates/ui-egui/src/organize_panel.rs | 2 +- crates/ui-egui/src/page_pointer.rs | 2 +- crates/ui-egui/src/page_selection_ui.rs | 2 +- crates/ui-egui/src/palette.rs | 52 +- crates/ui-egui/src/panels.rs | 30 +- crates/ui-egui/src/prepare.rs | 16 +- crates/ui-egui/src/print_ui.rs | 79 +- crates/ui-egui/src/protect.rs | 2 +- crates/ui-egui/src/recovery.rs | 60 +- crates/ui-egui/src/redact_ui.rs | 5 +- crates/ui-egui/src/scroll_speed.rs | 8 +- crates/ui-egui/src/search_ui.rs | 4 +- crates/ui-egui/src/side_panels.rs | 4 +- crates/ui-egui/src/sign_ui.rs | 3 +- crates/ui-egui/src/system_fonts.rs | 8 +- crates/ui-egui/src/theme.rs | 154 +- crates/ui-egui/src/wheel_boost.rs | 26 +- crates/ui-egui/src/widgets.rs | 176 +- crates/ui-egui/src/window_controls.rs | 9 +- crates/ui-egui/tests/it/actions.rs | 18 + crates/ui-egui/tests/it/attachment_row.rs | 9 +- crates/ui-egui/tests/it/bookmarks.rs | 6 +- crates/ui-egui/tests/it/combine_grid.rs | 32 +- crates/ui-egui/tests/it/commands.rs | 26 +- crates/ui-egui/tests/it/comments.rs | 5 +- crates/ui-egui/tests/it/control.rs | 97 +- crates/ui-egui/tests/it/dialog_chrome.rs | 8 +- crates/ui-egui/tests/it/editing.rs | 5 +- crates/ui-egui/tests/it/editor_taps.rs | 2 +- crates/ui-egui/tests/it/fonts.rs | 99 +- crates/ui-egui/tests/it/forms.rs | 6 +- crates/ui-egui/tests/it/pointer.rs | 27 +- crates/ui-egui/tests/it/prepare.rs | 4 +- crates/ui-egui/tests/it/print.rs | 30 +- crates/ui-egui/tests/it/recovery.rs | 17 + crates/ui-egui/tests/it/text_select.rs | 16 +- crates/ui-egui/tests/it/theme.rs | 4 +- crates/ui-egui/tests/it/ui.rs | 3 + crates/ui-egui/tests/it/view.rs | 20 +- crates/ui-egui/tests/it/viewer.rs | 170 +- .../tests/snapshots/panel_comments.txt | 4 +- deny.toml | 8 +- docs/egui-037-migration-plan.md | 812 +++++++ docs/gaps.md | 36 +- docs/localization-parity.md | 51 +- docs/localization.md | 47 +- docs/releasing.md | 13 +- docs/roadmap.md | 14 +- docs/target-app-parity.md | 7 +- .../linux/flatpak/ai.storyteller.pdfcraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/font-notices.ps1 | 48 + packaging/windows/package.ps1 | 52 +- packaging/windows/pdfcraft.wxs | 11 + packaging/windows/test-font-notices.ps1 | 43 + packaging/windows/test-msi.ps1 | 32 +- parity/acrobat-features.toml | 8 +- vendor/README.md | 31 +- 159 files changed, 6759 insertions(+), 1181 deletions(-) create mode 100644 apps/pdfcraft/tests/it/vendor_graph.rs create mode 100644 crates/fonts/tests/build_input.rs create mode 100644 docs/egui-037-migration-plan.md create mode 100644 packaging/windows/font-notices.ps1 create mode 100644 packaging/windows/test-font-notices.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac276f13c9..9d168c7aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Windows font notice regressions + shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 - name: Git dependencies come from storytold run: | set -euo pipefail @@ -73,7 +76,7 @@ jobs: # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} # Same craft-fonts commit as release.yml and freebsd.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # egui/winit/rfd, and the independent oracles (crates/oracle). packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fba20b1cc7..268da70ca1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,4 +26,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c31..a5c2ba7f7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: pdfcraft app-title: PdfCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 40626dc6ba..c5be9d7bc1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -298,14 +298,15 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -## Compiled in through dependencies (5) +## Compiled in through dependencies (6) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| -| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | ## Downloaded at build time (22) @@ -343,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 383272f3f7..8b2186d5b4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2439,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Ubuntu-Light.ttf" title = "Ubuntu Light" author = "Canonical Ltd. / Dalton Maag" @@ -2451,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Hack-Regular.ttf" title = "Hack Regular" author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)" @@ -2463,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/NotoEmoji-Regular.ttf" title = "Noto Emoji (monochrome)" author = "Google Inc." source = "https://github.com/googlefonts/noto-emoji" licence = "OFL-1.1" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/emoji-icon-font.ttf" title = "emoji-icon-font" author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca" +[[bundled]] +crate = "epaint_default_fonts" +version = "0.37.0" +path = "fonts/egui-icons.ttf" +title = "egui icons (monochrome emoji-icon-font subset)" +author = "John Slegers" +source = "https://github.com/jslegers/emoji-icon-font" +licence = "MIT" +kind = "font" +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" + [[asset]] path = "vendor/hayro-cmap/assets/cmaps.brotli" title = "Adobe CMap resources (predefined CJK CMaps)" @@ -3372,13 +3384,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" [[build_input]] name = "craft-fonts" option = "CRAFT_FONTS_DIR" -title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic" -author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)" -source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" +title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic" +author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)" +source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)" licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" diff --git a/Cargo.lock b/Cargo.lock index 07b8e4ef03..0a7e03a82e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,57 +15,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -235,6 +230,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1150,8 +1146,9 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ + "craft-winprint-gdi", "miniz_oxide 0.8.9", "roxmltree", "tempfile", @@ -1162,7 +1159,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ "dispatch2", "egui", @@ -1174,7 +1171,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ "egui", "log", @@ -1184,6 +1181,14 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1539,9 +1544,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1572,9 +1576,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1598,6 +1601,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1610,9 +1614,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1632,7 +1635,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1650,32 +1654,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1688,9 +1695,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1702,9 +1708,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1746,9 +1751,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1776,22 +1780,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1828,9 +1832,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1847,16 +1850,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1891,7 +1895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2072,6 +2076,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2322,7 +2332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2334,18 +2344,18 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -2509,6 +2519,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2517,7 +2536,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2528,7 +2547,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3069,12 +3088,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3308,7 +3327,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3850,6 +3869,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.14.0" @@ -4411,6 +4440,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "winsafe", "xattr", ] @@ -4500,6 +4530,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4508,7 +4549,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4519,6 +4559,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4973,7 +5014,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5523,7 +5564,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5593,7 +5634,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5723,6 +5764,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -6248,7 +6295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6470,7 +6517,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6804,6 +6851,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6839,7 +6897,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7029,9 +7087,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7057,14 +7115,14 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -7553,7 +7611,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7836,6 +7894,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7934,6 +7993,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -8374,3 +8451,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 6a91547f7e..cb69da41a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,11 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal @@ -75,11 +75,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" +# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass). +web-time = "1.1" [workspace.lints.rust] unsafe_code = "forbid" @@ -118,6 +120,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" } hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } -winit = { path = "vendor/winit" } -egui-wgpu = { path = "vendor/egui-wgpu" } vello_common = { path = "vendor/vello_common" } +# The egui and winit crates below are pinned to immutable storytold git revisions (egui +# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's +# earlier patches and their source guards, not path patches; the other `vendor/` entries above +# remain real path patches. See vendor/README.md and the migration plan. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8cacb0b585..63a8fe672c 100644 --- a/NOTICE +++ b/NOTICE @@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006). Vendored (vendor/, patched; see vendor/README.md): - hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). - hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index edc0ad434a..165fccc5e8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) @@ -165,10 +165,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | +| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | | Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | | Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (layout not mirrored) | +| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | | French | `fr` | 99.3% | partial | | Portuguese (Brazil) | `pt-br` | 100% of current union | partial | | Indonesian | `id` | 0% | none | @@ -188,7 +188,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -676,6 +676,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dcd..ad8243d594 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 6943ad6e44..02d6bc56c5 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -18,8 +18,8 @@ pdfcraft-ui-egui = { workspace = true } pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } -egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 96725cd8d5..8bc4d32f73 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -357,6 +357,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's + // provider would read every installed face, including proprietary ones, into every UI pass. + native.system_font_fallback = false; if renderer == eframe::Renderer::Wgpu { configure_gpu(&mut native); } @@ -795,6 +798,41 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D #[cfg(test)] mod tests { + /// The lockfile package block for `name`, or a panic naming the missing package. + fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) + } + + /// Interim graph check for the window stack while the reviewed fork revision is not yet + /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the + /// published manifest; local integration supplies the reviewed worktrees through an external + /// Cargo config patch. A git source must name the public storytold fork; a path source is the + /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested + /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is + /// pinned. No public revision is invented here. + fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package + } + use pdfcraft_ui_egui::control::Endpoint; fn endpoint() -> std::io::Result { @@ -1063,28 +1101,19 @@ mod tests { } } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. #[test] - fn egui_wgpu_carries_the_surface_size_fit() { - // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and + fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that - // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would - // bring the crash back: re-apply the patch, or drop the copy once an egui release has a - // fix (vendor/README.md). + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - // `surface_fit` is tested above; these keep it in the paths that size a surface. - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - for patch in [ - "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);", - "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());", - "pixels_per_point: pixels_per_point * surface_state.render_scale,", - "old_state.window_width,\n old_state.window_height,", - ] { - assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1140,24 +1169,20 @@ mod tests { assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. #[test] - fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() { - // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of - // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then - // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure - // panics before the app is created, and the OpenGL retry never runs. + fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface"); - for patch in [ - "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })", - "return Err(crate::WgpuError::ConfigureSurface(error));", - ] { - assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1191,6 +1216,8 @@ mod tests { } } + /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy + /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another @@ -1199,13 +1226,23 @@ mod tests { // dependency bump that resolves winit from crates.io again, or a re-vendored copy without // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); - assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); - let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; - assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); } const NVIDIA: (u32, u32) = (0x10de, 0x2684); diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs index bc05099a51..b09ace5f40 100644 --- a/apps/pdfcraft/tests/it/main.rs +++ b/apps/pdfcraft/tests/it/main.rs @@ -7,6 +7,7 @@ mod event { pub use winit::event::TouchPhase; } +mod vendor_graph; mod winit_macos_emoji_picker; mod winit_macos_ime_commit; mod winit_macos_ime_discard; diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs new file mode 100644 index 0000000000..a6a4d4b292 --- /dev/null +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -0,0 +1,81 @@ +//! Dependency-graph verification for the egui 0.37 window stack. +//! +//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` / +//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app +//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained +//! copies are not path patches. These tests check the resolved graph and the retained evidence +//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch +//! behaviour. + +/// Return the workspace root above the PdfCraft app crate. +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`. +fn locked_packages(name: &str) -> Vec { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock"); + let needle = format!("name = \"{name}\""); + lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect() +} + +/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something +/// to resolve by taking the first match. +fn locked(name: &str) -> String { + let mut packages = locked_packages(name); + assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len()); + packages.remove(0) +} + +/// The resolved package must be the exact canonical storytold git source at the reviewed +/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source` +/// line) is refused: it would make the graph depend on a developer's checkout. A published path +/// patch is refused as well. +fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { + let package = locked(name); + let expected_version = format!("version = \"{version}\""); + assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}"); + let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect(); + assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}"); + let expected_source = format!("\"git+{url}?rev={rev}#{rev}\""); + assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]); + + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); +} + +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. +#[test] +fn vendor_provenance_window_stack_graph_is_resolved() { + assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. + let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); + for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { + assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}"); + } + let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance"); + assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base"); + for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] { + assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork"); + } +} + +/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches. +#[test] +fn renderer_and_security_vendors_remain() { + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + for vendor in [ + "vendor/hayro", + "vendor/hayro-interpret", + "vendor/hayro-syntax", + "vendor/hayro-cmap", + "vendor/hayro-jbig2", + "vendor/hayro-postscript", + "vendor/lopdf", + "vendor/vello_common", + ] { + assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch"); + } +} diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index 6cedf57e33..b95058ba94 100644 --- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (8) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] -fn winit_commits_macos_text_inserted_outside_a_key_press() { +fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without // a composition. winit 0.30.13 reports `insertText` only as part of a key press or a // composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index 8701fc20a1..877f9e2a22 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (6) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] -fn winit_commits_macos_compositions_that_end_without_marked_text() { +fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input // methods that end a composition with `unmarkText` first lost the committed text, and Apple // Korean, which clears its marked text before inserting the key typed after a syllable (`한` diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index 52e65622f6..89efbd3d81 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (7) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] -fn winit_discards_the_macos_composition_when_ime_is_disallowed() { +fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text // but doesn't tell the input method, which keeps the composition and continues it once IME is // allowed again: the abandoned syllable comes back in the next field. vendor/winit calls diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index 76bb32ccbd..f18dde0f2a 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (10) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] -fn winit_retries_the_first_apple_korean_key_once() { +fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the // raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave // ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 30c9d4779c..f5f2aada2f 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (9) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] -fn winit_sends_a_key_committed_through_macos_ime_only_once() { +fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, // `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so // the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index 5422af59b5..5c8e892f25 100644 --- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (12) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] -fn winit_lets_macos_title_bar_drags_reach_the_app() { +fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit // moved the whole window for a drag that began on a document tab, and tabs could not be diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs index 96686f7ecf..8087219b21 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -1,3 +1,5 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. #![cfg(target_os = "linux")] use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; @@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; let input = RawInput { time: Some(frame as f64 / 60.0), - events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + events: vec![Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui_phase(phase), + modifiers, + source: egui::MouseWheelSource::Unknown, + }], ..Default::default() }; ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); @@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] -fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { +fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { let seen = wheel_trace(source, false); assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); @@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] -fn discrete_wheels_still_follow_current_modifiers() { +fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); assert!(seen[1].0 > 1.0); assert_eq!(seen[2].0, 1.0); assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] -fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { +fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. let seen = wheel_trace(AxisSource::Finger, false); assert!(seen[1].0 > 1.0); @@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] -fn unknown_sources_keep_the_existing_phase_policy() { +fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 691659e5a6..6469354cdc 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (2) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] -fn winit_keeps_the_wayland_text_input_until_the_seat_goes() { +fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a // touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when // a capability is added, so IME stops working in a running PdfCraft until it is restarted. diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index a1bdd11840..a8009591bf 100644 --- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (4) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] -fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { +fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the // window once its first frame is drawn. winit 0.30.13 applies the maximized state with // `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index aef55877d7..1df11d8a68 100644 --- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (3) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] -fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { +fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether // or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so // the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs index fed0cf3e42..a9bde6a1ee 100644 --- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,9 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] -fn winit_carries_the_x11_server_dpi_fallback() { +fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). // This guards PdfCraft's vendored copy, which workspace tests do not test itself. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index f244d1387a..61e5c8ebfa 100644 --- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (5) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] -fn winit_focuses_the_x11_input_context_it_replaces() { +fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. // On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the // new one to the next focus event, which never comes while PdfCraft's window keeps the focus: diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index bc87a16634..980c195709 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -2,7 +2,9 @@ //! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads //! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular //! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting -//! limits (Cloudflare Pages: 25 MiB per file). +//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional +//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm. +//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required. use std::fmt::Write as _; use std::path::PathBuf; @@ -10,18 +12,21 @@ use std::path::PathBuf; #[path = "src/cjk_scripts.rs"] mod cjk_scripts; +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match craft_fonts(&dir, required) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + } else if required { + println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing"); } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -30,26 +35,66 @@ fn main() { } } -/// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let manifest = dir.join("fonts/manifest.txt"); - println!("cargo::rerun-if-changed={}", manifest.display()); - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; +/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional +/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can +/// never pass the Chinese UI tests only by skipping them. +fn craft_fonts(dir: &std::path::Path, required: bool) -> Result { let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?; + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + match chinese.try_exists() { + Ok(false) if required => { + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + Ok(false) => {} + Ok(true) => match manifest(dir, &chinese, false, required) { + Ok(entries) => out.push_str(&entries), + // A broken optional addition must never discard the main Japanese/symbol/RTL faces. + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + }, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()), + } + } + Ok(out) +} + +/// One `CraftFont { .. }` initialiser per manifest line. +/// +/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not +/// fit its cap must fail the build rather than be silently left out, so a required build cannot +/// pass without the Chinese face it promises. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result { + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let mut out = String::new(); + // A required native Chinese manifest must add the Hans face itself, not merely some other + // CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised + // Simplified Chinese fallback is still absent. + let mut has_hans = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app - // moves to craft-fonts-build (storytold/craft-libs#213). + // Keep the upstream general CJK size cap. The separately pinned native Chinese UI + // face has its own bound; web builds retain the smaller general limit. A required + // Chinese face over its cap fails the build instead of being silently omitted. let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); - if cjk - && !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family) - && std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES) - { - continue; + if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) { + match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) { + cjk_scripts::CjkSizeDecision::Embed => {} + cjk_scripts::CjkSizeDecision::Skip => continue, + cjk_scripts::CjkSizeDecision::Reject => { + let cap = cjk_scripts::cjk_cap_bytes(family, wasm); + return Err(format!( + "{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it" + )); + } + } } // Arabic interface faces are small, so the web build keeps them too. let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); @@ -68,6 +113,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { if wasm && !web_face { continue; } + has_hans |= cjk_scripts::has_script_tag(scripts, "Hans"); let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); @@ -78,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } + if strict_cap && !has_hans { + return Err(format!( + "{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)", + manifest.display() + )); + } Ok(out) } + +#[cfg(test)] +mod manifest_tests { + use super::*; + + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. + #[test] + fn required_native_manifest_rejects_non_hans_faces_after_parsing() { + let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap(); + let path = root.join("manifest.txt"); + for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] { + std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap(); + assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}"); + assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable"); + } + std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap(); + assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {")); + std::fs::remove_file(root.join("face.ttf")).unwrap(); + assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs index 16a863c683..1058c6e61b 100644 --- a/crates/fonts/src/cjk_scripts.rs +++ b/crates/fonts/src/cjk_scripts.rs @@ -8,6 +8,11 @@ pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", " /// Any other CJK face over this size is left out. pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; +/// Reviewed native-only UI fallback from the separate Chinese manifest. +/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap. +pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"]; +pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20; + /// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; @@ -17,3 +22,76 @@ pub fn is_cjk_script(tag: &str) -> bool { let code = tag.trim().split('-').next().unwrap_or_default(); CJK_SCRIPTS.contains(&code) } + +/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces. +/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag +/// such as `Hans-HK` is not a `Hans` entry. +pub fn has_script_tag(scripts: &str, script: &str) -> bool { + scripts.split(',').any(|tag| tag.trim() == script) +} + +/// What `build.rs` does with one CJK face from a manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CjkSizeDecision { + /// The face fits its cap (or is a Japanese family embedded whole). + Embed, + /// An optional face over its cap: leave it out and keep the rest. + Skip, + /// A required face over its cap: fail the build rather than silently omit it. + Reject, +} + +/// The size cap that applies to `family` in this build. +pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 { + if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES } +} + +/// Decide whether a CJK face is embedded, skipped, or rejected. +/// +/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap; +/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build, +/// where an over-cap face must fail instead of quietly disappearing. +pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision { + if WHOLE_CJK_FAMILIES.contains(&family) { + return CjkSizeDecision::Embed; + } + if bytes <= cjk_cap_bytes(family, wasm) { + CjkSizeDecision::Embed + } else if required { + CjkSizeDecision::Reject + } else { + CjkSizeDecision::Skip + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. + #[test] + fn a_required_chinese_manifest_needs_an_exact_hans_entry() { + assert!(has_script_tag("Hans,Latn", "Hans")); + assert!(has_script_tag(" Latn , Hans ", "Hans")); + assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face"); + assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag"); + assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); + } + + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. + #[test] + fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { + let at_cap = MAX_NATIVE_UI_CJK_BYTES; + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip); + // The web build keeps the smaller general cap even for the native Chinese family. + assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject); + // Japanese document/UI families are embedded whole. + assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index e778a42ad7..dff96762aa 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -315,12 +315,13 @@ mod tests { } /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to - /// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the + /// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate + /// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts /// are 24.1 MB. #[test] fn cjk_faces_follow_the_size_rule() { - use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script}; + use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script}; // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); @@ -328,7 +329,10 @@ mod tests { let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); for face in &other { let len = face.bytes.len(); - assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name()); + let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family); + assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload"); + let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }; + assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name()); } let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); diff --git a/crates/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 0000000000..832cc2c16c --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Required manifest checks execute the same parser as the production build script. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index bc0454c999..e6432d8aec 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -283,8 +283,8 @@ fn spooler_arguments_and_printer_list() { assert_eq!( parse_lpstat(out), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() } ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); @@ -327,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() { assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() }, ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); + assert_eq!( + spool::printers_parsed(queues, None), + [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }] + ); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -414,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); - assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); - assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); - assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() }); } /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index fa39853a7f..8b94612145 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2265,6 +2265,74 @@ fn unselected_pages_keep_inherited_forms() { assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); } +#[test] +fn nested_resource_less_forms_keep_live_resources_after_redacting_another_page() { + for own_resources in [false, true] { + for still_drawn in [false, true] { + let original = b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"; + let mut extra = vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", original), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", if still_drawn { b"/Fm Do".as_slice() } else { b"q Q".as_slice() }), + ]; + if own_resources { + extra.push(stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 7 0 R /Relay 8 0 R >> >>", + b"/Relay Do", + )); + } + let resources = + if own_resources { "/XObject << /Fm 7 0 R /Relay 8 0 R /Wrapper 9 0 R >>" } else { "/XObject << /Fm 7 0 R /Relay 8 0 R >>" }; + let mut doc = one_page(b"/Fm Do", resources, extra); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let inherited = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let second_content = + doc.add(Object::Stream(Stream::from_raw(Dict::new(), if own_resources { b"/Wrapper Do".to_vec() } else { b"/Relay Do".to_vec() }))); + page.set(b"Contents".to_vec(), Object::Ref(second_content)); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), inherited); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + + let region = [[0.0, 190.0, 300.0, 220.0]]; + let surviving = if still_drawn { 6 } else { 0 }; + assert_eq!(under(&mut doc, 0, ®ion), 6, "the marked occurrence is real text"); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "the relay's original nested content is visible"); + mark(&mut doc, 0, ®ion, ""); + let report = apply(&mut doc, Some(&[0])).unwrap(); + assert_eq!((report.marks, report.glyphs), (1, 6)); + assert_eq!(under(&mut doc, 0, ®ion), 0); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "redacting another page must preserve live nested content"); + + // A full saved document must retain both the reachable resource and its content, + // without relying on an old incremental revision's otherwise-unreachable objects. + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(under(&mut saved, 0, ®ion), 0); + assert_eq!(under(&mut saved, 1, ®ion), surviving); + let page = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut holder = xo.as_dict().unwrap().clone(); + if own_resources { + let wrapper = saved.resolve(holder.get(b"Wrapper").unwrap()); + let Object::Stream(wrapper) = &*wrapper else { panic!("a real wrapper form") }; + let res = saved.resolve(wrapper.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + holder = xo.as_dict().unwrap().clone(); + } + assert_eq!(holder.contains(b"Fm"), still_drawn, "keep only resources a surviving nested form actually uses"); + if still_drawn { + let form = saved.resolve(holder.get(b"Fm").unwrap()); + let Object::Stream(form) = &*form else { panic!("the preserved original form") }; + assert_eq!(form.decoded().unwrap(), original); + } + } + } +} + /// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. fn inheriting_pages(n: usize) -> (Document, Vec) { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 39eaff6076..b155d117b2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,30 +11,31 @@ workspace = true [dependencies] craft-ui = { workspace = true } -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. -craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } -egui = { workspace = true, features = ["bytemuck"] } +egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } +# Monotonic deadlines for work that runs in `logic` with no UI pass (native and web). +web-time = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" base64 = "0.22" -# Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree. +# Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns +# display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" # Checks that an installed fallback font parses and has the glyphs, and reads the vertical metrics # that put fallback faces on the interface's baseline (#1676); the same skrifa egui uses. @@ -53,6 +54,9 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] +# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. +# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only. +craft-quit = { workspace = true, features = ["egui"] } # Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. # The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" @@ -81,6 +85,9 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] pdfcraft-xfa = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +# Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must +# be enabled on the test graph itself (a dependency's dev-features do not propagate). +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index 89cf2569e8..ddd8a0736c 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -301,16 +301,18 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool close } +/// Draw the custom-action editor: name, description and ordered steps; Save stays disabled until the name is +/// unique and non-empty and at least one step exists. fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Action name:")); - ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")); + let name_label = ui.label(tl!("Action name:")); + ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")).labelled_by(name_label.id); ui.end_row(); - ui.label(tl!("Description:")); - ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")); + let description_label = ui.label(tl!("Description:")); + ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")).labelled_by(description_label.id); ui.end_row(); }); ui.add_space(6.0); @@ -324,9 +326,9 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { for (i, s) in a.steps.iter_mut().enumerate() { ui.push_id(i, |ui| { ui.horizontal(|ui| { - ui.label(format!("{}. {}", i + 1, tl!(s.label()))); + let step_label = ui.label(format!("{}. {}", i + 1, tl!(s.label()))); if let Some(arg) = s.arg_mut() { - ui.add(crate::widgets::line(arg).desired_width(220.0)); + ui.add(crate::widgets::line(arg).desired_width(220.0)).labelled_by(step_label.id); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(tl!("Remove")).clicked() { diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index c8c510a11c..9b09c887d1 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -13,9 +13,8 @@ const TILE: egui::Vec2 = vec2(148.0, 118.0); const MATCHES_SHOWN: usize = 2; /// Whether `query` (already trimmed and lower-cased) is in `label` or its translation. -/// A right-to-left language's translations are in display order (their words run the other way) -/// while the query is in typing order, so there several words match one by one, as in the -/// command palette. +/// Translations and queries stay in logical order. For right-to-left labels, also accept +/// multiword queries in a different word order, as in the command palette. fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool { let translated = translated.to_lowercase(); label.to_lowercase().contains(query) @@ -115,7 +114,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { /// short shows in full on hover. fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); crate::home::card_lift(ui, t, rect, press); ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); @@ -186,11 +185,13 @@ mod tests { #[test] fn right_to_left_labels_match_a_query_typed_in_logical_order() { - let shown = crate::bidi::display_rtl("تنظيم الصفحات"); - assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display"); - assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true)); - assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word"); - assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there"); - assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact"); + let arabic = crate::i18n::Lang::from_code("ar").expect("Arabic is registered"); + let translated = crate::i18n::tr(arabic, "Organize pages"); + assert_eq!(translated, "تنظيم الصفحات", "the catalogue keeps logical text"); + assert!(label_hit("Organize pages", translated, "تنظيم الصفحات", true)); + assert!(label_hit("Organize pages", translated, "الصفحات", true), "one word"); + assert!(label_hit("Organize pages", translated, "الصفحات تنظيم", true), "RTL word-order fallback"); + assert!(!label_hit("Organize pages", translated, "تنظيم الملفات", true), "every word must be there"); + assert!(!label_hit("Organize pages", translated, "الصفحات تنظيم", false), "left-to-right labels stay exact"); } } diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index 536d8e4d97..1393c9d937 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -118,8 +118,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Appearance mode")); - egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { + let label = ui.label(tl!("Appearance mode")); + let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { app.execute(mode_command(preference)); @@ -127,6 +127,8 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } } }); + // The visible label names the combo box for assistive technology and tests. + combo.response.labelled_by(label.id); }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 50cae547e3..c5809c601f 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,18 +1,18 @@ -//! Visual order for single-line interface text that may hold right-to-left script (file names, -//! document titles). +//! Wrapping right-to-left text into logical lines, and test-only display-order helpers. //! -//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic -//! word join and run right to left, but it places the runs themselves left to right in logical -//! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display -//! order and leaves the letters of each word alone for the shaper. -//! -//! [`display_rtl`] does the same for the labels of a right-to-left interface language (the Arabic -//! catalog), whose base direction is right to left. +//! egui 0.37's epaint shapes text with the Unicode bidirectional algorithm (UAX #9), so +//! production code passes it logical (typing-order) text: [`wrap`] breaks a comment into logical +//! lines and lets epaint reorder each one. [`visual`], [`visual_auto`] and [`display_rtl`] are +//! kept for the tests that prove the old preordering is gone; they must not be used for painting +//! with egui 0.37, where they would reverse mixed-direction text a second time. +#[cfg(test)] use std::borrow::Cow; use std::ops::Range; -use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; +use unicode_bidi::BidiInfo; +#[cfg(test)] +use unicode_bidi::{Level, ParagraphInfo}; /// `text` rearranged for an egui call that lays out one line. Text without right-to-left /// characters comes back borrowed and unchanged. @@ -20,6 +20,7 @@ use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; /// The base direction is left to right, as the interface is: a file name keeps its extension /// at the end, the way the window title shows it. Use it for painting only; accessibility /// labels and anything stored keep the logical text. +#[cfg(test)] pub fn visual(text: &str) -> Cow<'_, str> { if !text.chars().any(is_rtl_script) { return Cow::Borrowed(text); @@ -30,6 +31,7 @@ pub fn visual(text: &str) -> Cow<'_, str> { } /// `text` in display order for the paragraph direction `base`, appended to `out`. +#[cfg(test)] fn push_visual(out: &mut String, text: &str, base: Level) { let info = BidiInfo::new(text, Some(base)); for para in &info.paragraphs { @@ -39,6 +41,7 @@ fn push_visual(out: &mut String, text: &str, base: Level) { /// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui /// needs it, appended to `out`. +#[cfg(test)] fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: Range) { let (levels, runs) = info.visual_runs(para, line); // First the true display order, every character left to right as it appears on screen. @@ -62,6 +65,7 @@ fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &Paragraph /// each such stretch goes back to logical order. What lies between them (spaces, punctuation, /// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include /// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right. +#[cfg(test)] fn push_for_shaper(out: &mut String, shown: &str) { let mut stretch: Vec = Vec::new(); for c in shown.chars() { @@ -78,7 +82,7 @@ fn push_for_shaper(out: &mut String, shown: &str) { /// One line of wrapped text (see [`wrap`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { - /// The line in display order, for one egui call that lays out one line. + /// The line in logical (reading) order. epaint shapes and reorders it for display. pub text: String, /// The paragraph reads right to left: the line belongs against the right edge. pub rtl: bool, @@ -89,14 +93,44 @@ pub fn has_rtl(text: &str) -> bool { text.chars().any(is_rtl_script) } +/// The paragraph direction for translated interface text: explicitly right to left for Arabic, +/// Kurdish and Persian, and `TextDirection::Auto` otherwise. A label whose first strong character is +/// Latin or a `{placeholder}` must not fall back to a left-to-right paragraph in an RTL locale. +pub fn ui_direction() -> egui::epaint::text::TextDirection { + if crate::i18n::current().rtl() { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::Auto } +} + +/// Lay out one line of translated interface text with the locale's explicit base direction. +pub fn layout_ui(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, ui_direction()) +} + +/// Lay out one line of file or path text left to right, so an Arabic file name keeps `.pdf` at +/// its visual end exactly as a Latin one does. The text stays logical for accessibility. +pub fn layout_ltr(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, egui::epaint::text::TextDirection::LeftToRight) +} + +fn layout_one_line( + ui: &egui::Ui, + text: impl Into, + font: egui::FontId, + color: egui::Color32, + direction: egui::epaint::text::TextDirection, +) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + job.direction = direction; + ui.painter().layout_job(job) +} + /// Multi-line text with right-to-left script (a comment in Persian, Arabic, Hebrew), broken -/// into lines that `fits` accepts (it measures a line in logical order) and each put in display -/// order by the Unicode bidirectional algorithm (UAX #9). +/// into logical-order lines that `fits` accepts (it measures a line in logical order). /// -/// Every paragraph takes its direction from its first strong character, as `dir="auto"` does, -/// so a Persian comment reads right to left with its Latin words and numbers left to right -/// inside it. Lines break between words, in reading order, and a word wider than a line -/// between characters. Use it for painting; copying and accessibility keep the logical text. +/// The lines keep reading order and each line's paragraph direction ([`Line::rtl`]): egui 0.37's +/// epaint already shapes every line with the Unicode bidirectional algorithm (UAX #9), so +/// preordering here would reorder mixed Persian/Latin text a second time. Lines break between +/// words, in reading order, and a word wider than a line between characters. Copying and +/// accessibility use the same logical text. pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let info = BidiInfo::new(text, None); let mut lines = Vec::new(); @@ -109,12 +143,7 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let content = para.range.start..para.range.start.saturating_add(whole.trim_end_matches(is_paragraph_separator).len()); let rtl = para.level.is_rtl(); for line in break_lines(text, content, &mut fits) { - let mut out = String::new(); - // unicode-bidi indexes the first level of a line, so an empty one would panic. - if !line.is_empty() { - push_line(&mut out, text, &info, para, line); - } - lines.push(Line { text: out, rtl }); + lines.push(Line { text: text.get(line).unwrap_or_default().to_owned(), rtl }); } } lines @@ -122,12 +151,18 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { /// One line of `text` in display order, its direction from its first strong character (an /// author's name). Text without right-to-left characters comes back borrowed and unchanged. +/// +/// Only the bidi unit tests use this now: interface labels keep logical text and let the egui +/// fork reorder, so no production call site preorders a label. +#[cfg(test)] pub fn visual_auto(text: &str) -> Cow<'_, str> { if !has_rtl(text) { return Cow::Borrowed(text); } - let lines: Vec = wrap(text, |_| true).into_iter().map(|l| l.text).collect(); - Cow::Owned(lines.join("\n")) + let base = BidiInfo::new(text, None).paragraphs.first().map_or(Level::ltr(), |para| para.level); + let mut out = String::with_capacity(text.len()); + push_visual(&mut out, text, base); + Cow::Owned(out) } /// The byte ranges of the lines `range` of `text` breaks into: as many words as `fits` accepts @@ -209,7 +244,9 @@ fn is_paragraph_separator(c: char) -> bool { /// - A short caption's closing colon stays on the right too, next to the widget it captions. /// /// Text without right-to-left characters comes back unchanged. Like [`visual`], this is for -/// painting one line; a label egui wraps breaks at the wrong end. +/// painting one line; a label egui wraps breaks at the wrong end. Kept for the tests that prove +/// the old preordering is gone; production catalogs stay logical. +#[cfg(test)] pub fn display_rtl(text: &str) -> String { if !text.chars().any(is_rtl_script) { return text.to_owned(); @@ -225,13 +262,17 @@ pub fn display_rtl(text: &str) -> String { } /// The most words a caption may have for its closing colon to stay on the right. +#[cfg(test)] const CAPTION_WORDS: usize = 3; /// First of the private-use characters that stand for placeholders while a line is reordered. /// They are strong left-to-right, like the names they replace. +#[cfg(test)] const PLACEHOLDER_BASE: u32 = 0xE000; /// Placeholders protected per line; a line with more keeps the rest as plain text. +#[cfg(test)] const MAX_PLACEHOLDERS: usize = 64; +#[cfg(test)] fn push_rtl_line(out: &mut String, line: &str) { let edge = |c: char| c.is_whitespace() || c == '·'; let core = line.trim_start_matches(edge); @@ -266,6 +307,7 @@ fn push_rtl_line(out: &mut String, line: &str) { /// `text` with each `{name}` placeholder and each keyboard shortcut (`⇧⌘+`) replaced by one /// stand-in character, and what they replaced, in order. A shortcut's symbols have no direction /// of their own, so left alone they would be drawn in reverse. +#[cfg(test)] fn mask_placeholders(text: &str) -> (String, Vec<&str>) { let mut masked = String::with_capacity(text.len()); let mut names: Vec<&str> = Vec::new(); @@ -286,6 +328,7 @@ fn mask_placeholders(text: &str) -> (String, Vec<&str>) { } /// The byte range of the first placeholder or keyboard shortcut in `text`. +#[cfg(test)] fn next_atom(text: &str) -> Option<(usize, usize)> { let placeholder = text.find('{').and_then(|open| { let len = text.get(open..)?.find('}')?; @@ -308,6 +351,7 @@ fn next_atom(text: &str) -> Option<(usize, usize)> { } /// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃. +#[cfg(test)] fn is_modifier_key(c: char) -> bool { matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}') } @@ -320,15 +364,18 @@ fn is_rtl_script(c: char) -> bool { /// The zero-width (non-)joiners inside a word (Persian `می‌روم`): egui shapes them with the /// letter before them. +#[cfg(test)] fn is_joiner(c: char) -> bool { matches!(c, '\u{200C}' | '\u{200D}') } /// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn. +#[cfg(test)] fn is_bidi_control(c: char) -> bool { matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } +#[cfg(test)] fn mirrored(c: char) -> char { match c { '(' => ')', @@ -474,50 +521,57 @@ mod tests { Line { text: text.to_owned(), rtl: false } } + /// Check that a Persian paragraph, including words joined by a zero-width non-joiner, reaches epaint as + /// one logical right-to-left run. #[test] - fn persian_paragraph_reads_right_to_left() { - // "I go to school": the words come out in reverse for egui's left-to-right placement. - assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("می‌روم مدرسه به من")]); + fn persian_paragraph_keeps_logical_order_for_epaint() { + // The line stays in reading order; epaint shapes and reorders it (see `on_screen`). + assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("من به مدرسه می‌روم")]); // The zero-width non-joiner keeps a word whole, so the shaper gets it as one run. assert_eq!(wrap_at("کتاب‌ها", 80), [rtl("کتاب‌ها")]); } + /// Check that Arabic text wraps into lines in logical reading order rather than visual order. #[test] fn arabic_lines_break_in_reading_order() { - // The first words go on the first line, each line reordered on its own. - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("اثنين واحد"), rtl("أربعة ثلاثة")]); - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("أربعة ثلاثة اثنين واحد")]); + // The first words go on the first line, in logical order for epaint to reorder. + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("واحد اثنين"), rtl("ثلاثة أربعة")]); + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("واحد اثنين ثلاثة أربعة")]); } + /// Check that each paragraph takes its base direction from its first strong character, not from leading + /// numbers or neutral text. #[test] fn base_direction_comes_from_the_first_strong_character() { // Latin first: a left-to-right paragraph with an Arabic phrase inside it. - assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See اثنين واحد here")]); - // Persian first: right to left, the Latin words and the number left to right inside it. - assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("است 2 نسخه PDF فایل")]); - assert_eq!(wrap_at("سلام Hello World", 80), [rtl("Hello World سلام")]); + assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See واحد اثنين here")]); + // Persian first: right to left, with the Latin words and number in reading order. + assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("فایل PDF نسخه 2 است")]); + assert_eq!(wrap_at("سلام Hello World", 80), [rtl("سلام Hello World")]); // Leading neutrals and numbers don't decide it. - assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("اثنين واحد .12")]); + assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("12. واحد اثنين")]); // Each paragraph finds its own direction. - assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("اثنين واحد"), ltr("OK then"), ltr(""), rtl("עולם שלום")]); + assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("واحد اثنين"), ltr("OK then"), ltr(""), rtl("שלום עולם")]); } + /// Check that right-to-left punctuation and brackets stay in logical order for epaint to mirror when + /// displaying. #[test] fn rtl_punctuation_and_brackets() { - // The sentence's full stop ends up on the left, brackets flip. - assert_eq!(wrap_at("سلام دنیا.", 80), [rtl(".دنیا سلام")]); - assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("ثلاثة (اثنين) واحد")]); - assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("است؟ آماده آیا")]); + // Logical text: epaint mirrors punctuation and brackets for display. + assert_eq!(wrap_at("سلام دنیا.", 80), [rtl("سلام دنیا.")]); + assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("واحد (اثنين) ثلاثة")]); + assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("آیا آماده است؟")]); } + /// Check that Persian and Arabic-Indic digits stay in logical order inside a right-to-left paragraph. #[test] fn persian_digits_go_to_the_shaper_reversed() { - // Persian and Arabic-Indic digits are Arabic script, which egui's shaper lays out right to - // left; they read left to right, so they are handed over reversed. - assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("شمسی ۲۰۴۱ سال")]); - assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("٢١ صفحه")]); - // Glued to a word, number and word swap places within one shaped run. - assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۲۱")]); + // Persian and Arabic-Indic digits stay in logical order; epaint's shaper preserves their + // left-to-right reading inside the right-to-left paragraph. + assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("سال ۱۴۰۲ شمسی")]); + assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("صفحه ١٢")]); + assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۱۲")]); } #[test] @@ -546,8 +600,30 @@ mod tests { let output = ctx.run_ui(egui::RawInput::default(), |ui| { for line in wrap(text, |_| true) { let galley = ui.painter().layout_no_wrap(line.text, egui::FontId::proportional(13.0), egui::Color32::WHITE); - let glyphs = galley.rows.iter().flat_map(|row| row.glyphs.iter()); - shown.push(glyphs.filter(|g| g.advance_width > 0.0).map(|g| g.chr).collect()); + let atlas = ui.fonts_mut(|fonts| fonts.image()); + let mut visual = String::new(); + for row in &galley.rows { + // 0.37 keeps glyphs in logical order for selection. Read their actual + // positions to test visual order instead of assuming vector order. + let mut glyphs: Vec<_> = row.glyphs.iter().filter(|g| g.advance_width > 0.0).collect(); + glyphs.sort_by(|a, b| a.pos.x.total_cmp(&b.pos.x)); + visual.extend(glyphs.into_iter().map(|g| match g.chr { + // `chr` remains logical even when the shaper mirrors the painted + // bracket. Read the bitmap's middle-row ink to check what is visible. + '(' | ')' => { + let [x0, y0] = g.uv_rect.min.map(usize::from); + let [x1, y1] = g.uv_rect.max.map(usize::from); + let y = usize::midpoint(y0, y1); + let ink: Vec = (x0..x1).map(|x| f32::from(atlas[(x, y)].a())).collect(); + let total: f32 = ink.iter().sum(); + assert!(total > 0.0 && !ink.is_empty(), "a bracket must have visible ink"); + let center = ink.iter().enumerate().map(|(i, alpha)| i as f32 * alpha).sum::() / total; + if center < (ink.len() - 1) as f32 / 2.0 { '(' } else { ')' } + } + _ => g.chr, + })); + } + shown.push(visual); } }); output.drop_without_applying_deltas(); @@ -573,6 +649,64 @@ mod tests { assert_eq!(on_screen("واحد (اثنين)."), [format!(".({}) {}", rev("اثنين"), rev("واحد"))]); } + /// A file name keeps its extension at the visual end even when the first strong word is + /// Arabic: with an explicit `LeftToRight` base, `.pdf` is right of every Arabic glyph. + #[test] + fn file_names_are_laid_out_left_to_right() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let mut positions: Vec<(char, f32)> = Vec::new(); + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ltr(ui, "واحد اثنين.pdf", egui::FontId::proportional(13.0), egui::Color32::WHITE); + positions = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + }) + .drop_without_applying_deltas(); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + let leftmost_extension = positions.iter().filter(|(c, _)| matches!(*c, '.' | 'p' | 'd' | 'f')).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(rightmost_arabic < leftmost_extension, "the extension must stay to the right: {positions:?}"); + let last = positions.iter().filter(|(c, _)| !c.is_whitespace()).max_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(last.map(|(c, _)| *c), Some('f'), "the painted line ends in the extension"); + } + + /// Arabic/Kurdish labels get an explicit right-to-left paragraph, even when their first + /// strong character is Latin. With English the same text stays left to right. + #[test] + fn arabic_ui_labels_use_an_explicit_paragraph_direction() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let read = |lang: &str| { + crate::i18n::set_current(crate::i18n::Lang::from_code(lang).expect("registered language")); + let mut got = None; + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ui(ui, "PDF واحد", egui::FontId::proportional(13.0), egui::Color32::WHITE); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + got = Some((galley.job.direction, positions)); + }) + .drop_without_applying_deltas(); + got.expect("one layout") + }; + let (direction, positions) = read("ar"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::RightToLeft); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "in an RTL paragraph the first Latin run is at the right: {positions:?}"); + + let (direction, positions) = read("en"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::Auto); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let leftmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(p < leftmost_arabic, "with English the Latin run stays at the left: {positions:?}"); + } + #[test] fn single_line_with_its_own_direction() { assert!(matches!(visual_auto("Ada Lovelace"), Cow::Borrowed("Ada Lovelace"))); diff --git a/crates/ui-egui/src/bulk_fields.rs b/crates/ui-egui/src/bulk_fields.rs index 3101724cff..fe401f0574 100644 --- a/crates/ui-egui/src/bulk_fields.rs +++ b/crates/ui-egui/src/bulk_fields.rs @@ -283,7 +283,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool egui::Grid::new("bulk-field-properties").num_columns(3).spacing([16.0, 10.0]).show(ui, |ui| match d.tab { Tab::General => { row(ui, "Tooltip:", &mut d.tooltip, |ui, v| { - ui.add(egui::TextEdit::singleline(v).desired_width(280.0)); + ui.add(egui::TextEdit::singleline(v).desired_width(280.0)).accessible_name(tl!("Tooltip")); }); bool_row(ui, "Read Only", &mut d.read_only); bool_row(ui, "Required", &mut d.required); diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 70a6dec2c0..4cbfa28177 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -566,7 +566,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let is_selected = selected.as_deref() == Some(f.name.as_str()); resp.widget_info(|| { let label = if row.value.is_empty() { format!("{}. {}", k + 1, f.name) } else { format!("{}. {}: {}", k + 1, f.name, row.value) }; - egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, label) + egui::WidgetInfo::selected(egui::Role::Button, true, is_selected, label) }); if reveal.as_deref() == Some(f.name.as_str()) { resp.scroll_to_me(Some(egui::Align::Center)); @@ -631,8 +631,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { [(up, "chevron-up", true, tl!("Earlier in calculation order")), (down, "chevron-down", false, tl!("Later in calculation order"))] { let to = if earlier { k.checked_sub(1) } else { Some(k + 1).filter(|&to| to < count) }; - let b = ui.interact(r, ui.id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, to.is_some(), format!("{tip}: {}", f.name))); + let b = + ui.interact(r, ui.scope_id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 1cacc4e163..d470378070 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -141,7 +141,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, view: /// way, choose its look (original, colour, greyscale, black and white) and clean-up level, /// Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`). pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Tokens, allowed: bool) { - egui::Area::new(egui::Id::new("camera-bar")) + egui::Area::new(egui::Id::unique("camera-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -183,7 +183,7 @@ pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Token let icon = |ui: &mut egui::Ui, name: &str, tip: &str| { let r = ui.add_enabled(allowed, egui::Button::image(crate::icons::image(name, 18.0, t.text)).frame(false)); // Named for screen readers (and tests), not only on hover. - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, allowed, tip)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allowed, tip)); r.on_hover_text(tip).clicked() }; if icon(ui, "rotate-ccw", tl!("Rotate counterclockwise")) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fc0ee07c68..4f00aa6bec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -234,6 +234,18 @@ pub struct Find { /// (page, glyph range) in document order. pub matches: Vec<(usize, Range)>, pub current: Option, + /// The page a search started from while its match is picked automatically: a text layer + /// that arrives out of order can still supply an earlier match, and the automatic choice + /// follows this page. Cleared when the user picks a match or moves the view themselves. + pub auto_from: Option, + /// The page the automatic choice last put the view on (the search's starting page until it + /// chose anything). Compared with the viewed page, it tells an automatic scroll from one + /// the reader made themselves; `None` with `auto_from` once the choice is the user's. + pub auto_page: Option, + /// The reader ended automatic following (by navigating, scrolling, or picking a match). + /// This is distinct from a search that never followed: a late match must be listed without + /// being revealed by scrolling, even when the reader moved before the first match arrived. + pub follow_ended: bool, pub focus: bool, pub case_query: String, /// Find options (Acrobat's: case-sensitive, whole words only). @@ -243,6 +255,51 @@ pub struct Find { pub in_panel: bool, } +/// What a text layer arriving after a search began may do with the automatic choice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Follow { + /// The reader has not moved the view since the automatic choice: it may follow `from`. + Active(usize), + /// The reader navigated, scrolled or picked a match, so no automatic choice may reveal. + Ended, + /// No automatic choice is being followed (the search has not started, or the reader chose). + Never, +} + +impl Find { + /// The first match at or after `page`, or the first match when none follows (the search + /// wraps round the document). + fn first_at_or_after(&self, page: usize) -> Option { + self.matches.iter().position(|(p, _)| *p >= page).or_else(|| (!self.matches.is_empty()).then_some(0)) + } + + /// How a text layer arriving now relates to the automatic choice. Once the reader has moved + /// the view (page box, Pages panel, outline, view history or any scrolling), automatic + /// following ends: a later layer must not pull them back to an anchor-derived match. + /// `Ended` is deliberately distinct from `Never`, so a first match arriving after that move + /// is not revealed by a flash meant only for a search that is still following. + fn follow_page(&mut self, viewed: usize) -> Follow { + if self.follow_ended { + return Follow::Ended; + } + match (self.auto_from, self.auto_page) { + (Some(from), Some(page)) if page == viewed => Follow::Active(from), + (Some(_), _) | (_, Some(_)) => { + self.stop_following(); + Follow::Ended + } + (None, None) => Follow::Never, + } + } + + /// Ends automatic following: the selected match is now the user's choice to keep. + fn stop_following(&mut self) { + self.auto_from = None; + self.auto_page = None; + self.follow_ended = true; + } +} + /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] struct Selection { @@ -730,6 +787,30 @@ impl DocView { self.auto_scroll.panning() } + /// Abandon only pointer previews: clearing egui's held button must not commit an app-owned + /// grab through a tool's `!primary_down` fallback. Keep selections, text drafts and edits + /// that were already completed before cancellation. + pub(crate) fn cancel_pointer_gestures(&mut self) { + self.marquee = None; + self.marquee_done = None; + self.comments.gesture = None; + self.links.drag = None; + self.sign.drag = None; + self.redact_drag = None; + self.crop_drag = None; + self.block_drag = None; + self.org_drag = None; + self.panel_drag = None; + self.selection_drag = false; + self.auto_scroll.cancel(); + self.prepare.cancel_pointer_gesture(); + self.content.cancel_pointer_gesture(); + self.objects.cancel_pointer_gesture(); + if let Some(image) = &mut self.image_selection { + image.cancel_pointer_gesture(); + } + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -1035,6 +1116,10 @@ impl DocView { self.last_queue.clear(); self.selection = None; self.all_text = false; + // A content change is not reader navigation: keep the automatic anchor so the + // re-extracted layers still follow it, and keep `follow_ended` so a reader who already + // took over stays in control. `document_changed` clamps `current`; if the view no longer + // sits where the automatic choice left it, `follow_page` ends it. if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1225,6 +1310,16 @@ impl DocView { f.case_query = f.query.clone(); f.matches.clear(); f.current = None; + // Refining the query keeps the page the reader started searching from, even when an + // automatic choice has already scrolled them to a match. Only a view the reader moved + // themselves re-anchors the search on the page now shown. + let from = match (f.auto_from, f.auto_page) { + (Some(from), Some(page)) if !f.follow_ended && page == self.current => from, + _ => self.current, + }; + f.auto_from = Some(from); + f.auto_page = Some(self.current); + f.follow_ended = false; if f.case_query.trim().is_empty() { return; } @@ -1236,12 +1331,12 @@ impl DocView { } } f.matches.sort_by_key(|(p, r)| (*p, r.start)); - if !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); + // First match at or after the page the search started from. + if let Some(i) = f.first_at_or_after(from) + && let Some(&(p, _)) = f.matches.get(i) + { f.current = Some(i); - let (p, _) = f.matches[i]; + f.auto_page = Some(p); self.flash_match(p); } } @@ -1251,21 +1346,33 @@ impl DocView { if f.case_query.trim().is_empty() { return; } - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + // An automatic choice follows the page it was anchored on, so a page whose text + // arrives late can still supply an earlier match. Once the reader has moved the view, + // this arrival must not pull them back; the choice then becomes theirs to keep. + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != page); f.matches.extend(text.find_opts(&f.case_query, f.case_sensitive, f.whole_words).into_iter().map(|r| (page, r))); f.matches.sort_by_key(|(p, r)| (*p, r.start)); - f.current = current_key.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); - if f.current.is_none() && !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); - f.current = Some(i); - let (p, _) = f.matches[i]; - if current_key.is_none() { + if let Follow::Active(from) = follow { + f.current = f.first_at_or_after(from); + let selected = f.current.and_then(|i| f.matches.get(i).cloned()); + // Follow a changed automatic choice; the view is still where the last one left it. + if selected != previous + && let Some((p, _)) = selected + { + f.auto_page = Some(p); self.flash_match(p); } + return; } + // A match the user picked keeps its identity, exactly as before automatic following. + // When it is gone (or none was picked), the first match at or after the viewed page is + // selected for the panel, but the view stays put: `Ended` and `Never` have no automatic + // choice to reveal, and a reader who moved before the first layer arrived must not be + // pulled back by that late match. + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); } /// Go to match `i` (the Search panel's results). @@ -1273,6 +1380,7 @@ impl DocView { let Some(f) = self.find.as_mut() else { return }; let Some(&(p, _)) = f.matches.get(i) else { return }; f.current = Some(i); + f.stop_following(); self.go_to_page(p); self.goto_point = None; self.flash_match(p); @@ -1288,6 +1396,7 @@ impl DocView { let c = f.current.unwrap_or(0); let next = if forward { (c + 1) % n } else { (c + n - 1) % n }; f.current = Some(next); + f.stop_following(); let p = f.matches[next].0; // Only explicit search navigation joins history; background refresh uses flash_match alone. self.go_to_page(p); @@ -1341,8 +1450,18 @@ impl DocView { self.wheel_boost_y.reset(); } + /// Ends automatic search following after the reader navigated or scrolled the view. A + /// programmatic search flash never comes through here; it sets `goto` and `current` directly + /// so that its own scroll cannot be mistaken for the reader's. + fn end_find_following(&mut self) { + if let Some(f) = self.find.as_mut() { + f.stop_following(); + } + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); + self.end_find_following(); if page != self.current { // The view history (Previous view / Next view). if self.back.last() != Some(&self.current) { @@ -1471,7 +1590,7 @@ impl DocView { /// Page-editor focus and drafts belong to a document, even while another tab is active. pub(crate) fn page_input_id(&self) -> egui::Id { - egui::Id::new(("page-input", self.id.0)) + egui::Id::unique(("page-input", self.id.0)) } /// The idle page box and unchanged-input lookup share the same label fallback. @@ -1504,6 +1623,11 @@ impl DocView { /// View ▸ Page navigation ▸ Previous view (`false`) / Next view (`true`). pub fn view_history(&mut self, forward: bool) -> bool { + let has_target = if forward { !self.forward.is_empty() } else { !self.back.is_empty() }; + if !has_target { + return false; + } + self.end_find_following(); let (from, to) = if forward { (&mut self.forward, &mut self.back) } else { (&mut self.back, &mut self.forward) }; let Some(page) = from.pop() else { return false }; to.push(self.current); @@ -2127,12 +2251,17 @@ impl DocView { log::warn!("page {} text: {}", r.request.page + 1, r.error.unwrap_or_default()); self.text_failed.insert(r.request.page); if let Some(f) = self.find.as_mut() { - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != r.request.page); - f.current = current_key.and_then(|k| f.matches.iter().position(|m| *m == k)); - if f.current.is_none() && !f.matches.is_empty() { - let cur = self.current; - f.current = Some(f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0)); + match follow { + // An automatic choice keeps following its anchor; a failure never + // flashes, so the view stays exactly where it was. + Follow::Active(from) => f.current = f.first_at_or_after(from), + Follow::Ended | Follow::Never => { + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); + } } } } @@ -2708,7 +2837,7 @@ fn page_scroll( id: egui::Id, draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, ) -> egui::scroll_area::ScrollAreaOutput { - ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner + ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a @@ -2717,6 +2846,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } // The Search panel closed: its search moves to the find bar. let search_open = app.right == Some(crate::RightPanel::Search); if let Some(f) = app.views[index].find.as_mut() @@ -2913,7 +3045,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // even while the page can't turn. ui.input(|i| { for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, phase, modifiers } = e { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; if fits { @@ -2964,8 +3096,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { line_base.y = 0.0; boost_extra.y = 0.0; } - if now < view.precise_scroll_until { - if ui.input(|i| i.is_scrolling()) { + // egui smooths a Point event that moved at least 8 points, keeping part of it for later + // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, + // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held + // point input measured 33 points for a 30-point event). Keep hiding the eased copy until + // egui says the action is over, then forget the suppression. + let scrolling = ui.input(|i| i.is_scrolling()); + if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { + if scrolling { // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace // avoids a delayed duplicate without prolonging suppression after the tail is gone. view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); @@ -2987,10 +3125,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } else { precise_scroll = Vec2::ZERO; - if line_base != Vec2::ZERO || has_page { - // Selection handling below uses zero to distinguish an inactive window. - view.precise_scroll_until = 0.0; - } + // Selection handling below uses zero to distinguish an inactive window. Clearing a past + // deadline also keeps a later line/page action from being mistaken for the old tail. + view.precise_scroll_until = 0.0; } if has_page && paging_owns_vertical { // Page input belongs to egui on the free axis; the pager still owns vertical motion. @@ -3012,6 +3149,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // Any reader-owned motion ends automatic search following, even when it stays on the same + // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the + // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so + // its programmatic scroll is never mistaken for a reader action. + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. let row = shown_row(view); @@ -3061,6 +3206,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { ui.input(|i| i.pointer.delta()) }; + if moved != Vec2::ZERO { + view.end_find_following(); + } // Within the content, as egui keeps it: past an end the page would show there for a // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); @@ -3203,7 +3351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| { + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -4010,6 +4158,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { + view.end_find_following(); ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); // Keep scrolling while the pointer rests there and sends no events. ui.ctx().request_repaint(); @@ -4113,7 +4262,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::new("copy-image-under-menu"); + let key = egui::Id::unique("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); let hit = match cached { Some(c) => c.2, @@ -4144,7 +4293,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // below the window's top). Within a point, as for wheel paging, so layout rounding // can't hide an edge. let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.offset.y <= MARGIN - view.page_gap() + 1.0, out.state.offset.y >= max_y - 1.0) + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); view.viewport_screen = out.inner_rect; @@ -4159,7 +4308,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; // pages with nothing on screen yet still queue. - let scroll_offset = out.state.offset; + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } let now_settle = ui.input(|i| i.time); if (scroll_offset - view.last_scroll).length() > 2.0 { view.pan_hold_until = now_settle + 0.25; @@ -4292,7 +4454,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. - let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_max_width(320.0); @@ -4566,26 +4728,29 @@ fn selection_actions( let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; - egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); + egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + for (label, action) in [ + (tl!("Add comment"), SelectionAction::Note), + (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + (tl!("Replace text"), SelectionAction::Replace), + ] { + if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { + chosen = Some(action); + } } - } - if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { - chosen = Some(SelectionAction::Copy); - } + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { + chosen = Some(SelectionAction::Copy); + } + }); }); - }); - }); + }, + ); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { // Copying closes the bar but keeps the words selected, for ⌘C or a second action. @@ -4754,7 +4919,7 @@ fn page_size_hint(view: &DocView, info: &DocInfo, units: crate::units::Unit, are return; } let text = units.size(f64::from(page.width), f64::from(page.height)); - egui::Area::new(egui::Id::new("page-size-hint")) + egui::Area::new(egui::Id::unique("page-size-hint")) .order(egui::Order::Foreground) .interactable(false) .pivot(egui::Align2::LEFT_BOTTOM) @@ -4781,7 +4946,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: let mut close = false; let mut step: Option = None; let searched = view.texts.len() + view.text_failed.len(); - egui::Area::new(egui::Id::new("find-bar")) + egui::Area::new(egui::Id::unique("find-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -4796,11 +4961,11 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: ui.horizontal(|ui| { ui.add(icons::image("search", 16.0, t.text_muted)); let edit = egui::TextEdit::singleline(&mut find.query) - .id(egui::Id::new("find-input")) + .id(egui::Id::unique("find-input")) .hint_text(tl!("Find text")) .desired_width(220.0) .frame(egui::Frame::NONE); - let r = ui.add(edit); + let r = ui.add(edit).accessible_name(tl!("Find text")); if find.focus { r.request_focus(); find.focus = false; @@ -5111,7 +5276,7 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let pos = area.left_top() + vec2(14.0, 14.0); - egui::Area::new(egui::Id::new("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.border)) @@ -5173,7 +5338,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5252,7 +5417,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5386,7 +5551,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)) .frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { view.grid_zoom_request = Some(1.0); } @@ -5449,8 +5614,8 @@ fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool { /// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { - let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.scope_id().with(("org-gap", gap)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.clone())); let hot = resp.hovered(); if hot { ui.painter().line_segment([pos2(at.x, at.y - height / 2.0), pos2(at.x, at.y + height / 2.0)], Stroke::new(3.0, t.accent)); @@ -5514,7 +5679,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let Some(p) = info.pages.get(i) else { break }; let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell); view.need_thumbnail(i, c.intersects(ui.clip_rect())); - let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); + let resp = ui.interact(c, ui.scope_id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); cells.push((i, c)); if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); @@ -5533,7 +5698,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool drop = true; } let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), page_label.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); let size = pr.size(); let selected = view.selected.contains(&i) || (view.selected.is_empty() && i == view.current); @@ -5586,7 +5751,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let check_rect = Rect::from_min_size(c.min + vec2(8.0, 8.0), Vec2::splat(20.0)); let check = ui.push_id(("org-check", i), |ui| ui.put(check_rect, widgets::checkbox_without_text(&mut checked))).inner; let label = crate::i18n::fmt(tl!("Select page {label}"), &[("label", &p.label)]); - check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label.clone())); + check.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, checked, label.clone())); if check.on_hover_text(label).changed() { view.click_page(i, egui::Modifiers::COMMAND, false); // The document shows the page picked here when the grid closes, as after a @@ -6148,14 +6313,445 @@ trailer << /Root 1 0 R >> assert_eq!(v.goto, Some((3, 0.5))); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); - v.current = 0; v.find.as_mut().unwrap().current = None; - v.refresh_find_page(3); - assert_eq!(v.current, 3); - assert!(v.back.is_empty()); + v.texts.insert(1, Arc::new(find_test_text("match"))); + v.refresh_find_page(1); + assert_eq!(v.current, 1, "an automatic late match still follows its anchor"); + assert!(v.back.is_empty(), "automatic search updates do not record history"); assert_eq!(v.forward, vec![4]); } + /// One glyph per character on one line, for the search-order tests below. + fn find_test_text(s: &str) -> PageText { + PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + } + } + + /// A text-layer result for `page`, for the search tests below. + fn find_text_result(page: usize, text: &str) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }, + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + } + } + + /// Text layers arrive in whatever order the workers finish, not in page order. A search + /// started on page 1 must settle on page 1's match even when page 2's text arrives first, + /// instead of keeping the later match picked only because page 1 had not been read yet. + #[test] + fn automatic_search_match_follows_the_viewed_page_when_text_arrives_out_of_order() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!(v.find.as_ref().unwrap().current, None, "no text yet, so no match"); + + let ctx = egui::Context::default(); + let request = |page| RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + let result = |page, text: &str| pdfcraft_render::RenderedPage { + request: request(page), + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + }; + v.last_queue = vec![request(1), request(0)]; + // Page 2's text finishes first, as it can when the workers race. + v.receive_result(&ctx, result(1, "Second page sample text")); + assert_eq!(v.find.as_ref().unwrap().current, Some(0), "the only known match is picked while page 1 is pending"); + + v.receive_result(&ctx, result(0, "Panel sample draft notes")); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(0), "page 1's match takes over once its text arrives"); + assert_eq!(v.current, 0, "the view follows the first match at or after the page being viewed"); + } + + /// A match the user picked (a click in the Search panel, or ⌘G) is not moved by a text + /// layer that arrives later: only automatic choices follow the viewed page. + #[test] + fn a_user_picked_search_match_stays_put_when_earlier_text_arrives() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.texts.insert(1, Arc::new(find_test_text("Second page sample text"))); + v.refresh_find_page(1); + v.go_to_match(0); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "an explicit choice is not automatic"); + + v.texts.insert(0, Arc::new(find_test_text("Panel sample draft notes"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(1), "the picked match keeps its identity after page 1 is inserted"); + assert_eq!(v.current, 1, "the user's page does not change under them"); + } + + /// The automatic choice may move the view only while the reader has left it where that + /// choice put it. Navigating by the page box/Pages panel/outline or by view history is the + /// reader's decision: a text layer arriving afterwards must not pull them back to an + /// anchor-derived match. (The harness covers same-page wheel and pointer scrolling.) + #[test] + fn a_late_automatic_match_does_not_override_the_readers_navigation() { + enum Nav { + Page, + History, + } + for nav in [Nav::Page, Nav::History] { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (2, Some(2)), "the automatic match was shown"); + match nav { + // The page box, the Pages panel and outline destinations all end here. + Nav::Page => v.go_to_page(1), + // View ▸ Page navigation ▸ Previous view writes `current` directly. + Nav::History => { + v.back = vec![1]; + assert!(v.view_history(false)); + } + } + assert_eq!(v.current, 1); + v.texts.insert(0, Arc::new(find_test_text("First page sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, None, "the reader's navigation ends automatic following"); + assert_eq!(f.auto_page, None); + assert_eq!(v.current, 1, "a late match cannot pull the reader back"); + } + } + + /// Typing on while the results arrive must not re-anchor the search on a page the automatic + /// flash picked. The anchor stays the page the reader was on when they started searching. + #[test] + fn refining_the_query_keeps_the_page_the_search_started_from() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample text"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (2, Some(0)), "the first query's match scrolled the view"); + + // Page 1 has a match for the refined query, and so has page 3. Anchored on the page the + // automatic flash reached, the search would pick page 3; the starting page picks page 1. + v.texts.insert(0, Arc::new(find_test_text("Panel draft text"))); + v.find.as_mut().unwrap().query = "text".into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader searched from"); + assert_eq!(f.matches.first().map(|(p, _)| *p), Some(0)); + assert_eq!(f.current, Some(0)); + assert_eq!(v.current, 0); + } + + /// ⌘G / Ctrl+G goes through `find_step`, an explicit choice like a Search-panel click: a + /// text layer arriving later must not undo it. + #[test] + fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (1, Some(0))); + + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::G, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + }); + shortcuts(&mut v, &ctx, Default::default()); + + assert_eq!(v.current, 2, "the keyboard step chose the next match"); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); + assert_eq!(v.back, vec![1], "an explicit search jump records its origin"); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the chosen match keeps the view"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(2)); + } + + /// A page whose text extraction failed keeps the automatic anchor: the failure selects the + /// next match without moving the view, and a later text layer can still follow the anchor. + #[test] + fn a_failed_text_layer_keeps_the_automatic_anchor_without_jitter() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1))); + + let goto = v.goto; + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![2]); + assert_eq!(f.current, Some(0), "the remaining match is selected"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)), "a failure does not end automatic following"); + assert_eq!(v.current, 1, "a failure never moves the view"); + assert_eq!(v.goto, goto, "a failure never flashes the match"); + + // The search still follows its original page when that page's text arrives. + v.last_queue = vec![RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the anchor's earlier match takes over"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0)); + } + + /// A reader who navigated before a page's text failed is not pulled back by a later + /// matching layer: the failure path must preserve the same `Ended` state as a successful + /// one, and the result list may select the late match without revealing it. + #[test] + fn a_failed_text_layer_after_navigation_does_not_reveal_a_late_match() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended, "the reader navigated before any match arrived"); + + let ctx = egui::Context::default(); + let failed = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![failed]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request: failed, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a failure after navigation does not restart following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "a failure never moves the view"); + assert_eq!(v.goto, moved); + + let arrived = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![arrived]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "the late matching layer does not pull the reader back"); + assert_eq!(v.goto, moved, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// The reader can navigate before any text layer has supplied a match. That navigation + /// still ends automatic following, so the first late match is listed but not revealed by + /// a flash that would pull the reader back. + #[test] + fn navigation_before_the_first_match_is_not_overridden_by_a_late_text_layer() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current, None, "no text layer has arrived yet"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + v.go_to_page(2); + assert_eq!(v.goto, Some((2, 0.0)), "the reader moved to page 3"); + assert!(v.find.as_ref().unwrap().follow_ended, "moving the view ends automatic following"); + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(1, "late sample")); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the late first match does not pull the reader back"); + assert_eq!(v.goto, Some((2, 0.0)), "no flash was issued"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the result list still selects the match"); + } + + /// A page-box jump to the page already shown is still the reader's navigation: automatic + /// following must end, so a late earlier match is listed without moving the view. (The + /// harness covers wheel, scrollbar and pointer scrolling through the production paint path.) + #[test] + fn a_same_page_page_box_jump_ends_automatic_following() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + // Choosing the page already shown still ends following; it stays on page 2 at its top. + v.go_to_page(1); + let shown = v.goto; + assert_eq!(shown, Some((1, 0.0))); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late earlier match does not move the reader within the page"); + assert_eq!(v.goto, shown, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the page-2 match stays selected"); + } + + /// A document-wide invalidation (a layer toggle, an edit, undo/redo) clears results for + /// re-extraction but is not the reader navigating. Automatic following keeps its anchor so + /// the re-extracted layers still reveal the automatic choice; a reader who already took + /// over stays in control. If `document_changed` clamps the view away from that anchor, the + /// next layer ends following instead of revealing a match from the stale page. + #[test] + fn content_invalidation_keeps_the_following_state() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.matches.is_empty() && f.current.is_none(), "results wait for the re-extracted text"); + assert!(!f.follow_ended, "a content change is not reader navigation"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1))); + + // Re-extracted text still follows the page the search started from. + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the re-extracted match is revealed by the still-active search"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + // A reader who navigated first keeps the view they chose across the invalidation. + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended); + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "late sample")); + assert_eq!(v.current, 2, "no delayed reveal after the reader navigated"); + assert_eq!(v.goto, moved, "no flash was issued"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// A document edit can shrink the page count. `document_changed` clamps `current` but leaves + /// the automatic anchor's page index alone; a re-extracted layer must then end following + /// rather than reveal a match by scrolling from a page that no longer exists. + #[test] + fn a_page_count_shrink_ends_following_before_a_late_reveal() { + let mut v = view(5, PageLayout::Single, true); + v.current = 0; + v.texts.insert(4, Arc::new(find_test_text("last sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (4, Some(4)), "the automatic choice is on the last page"); + + let info = pdfcraft_render::DocInfo { + pages: (0..2) + .map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + v.document_changed(&info); + assert_eq!(v.current, 1, "the view clamps to the last remaining page"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "the content change itself keeps the automatic state"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(4)), "the anchor still names the removed page"); + let goto = v.goto; + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a layer for the remaining pages ends the stale follow"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late layer cannot pull the view back"); + assert_eq!(v.goto, goto, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the match"); + } + + /// Typing refinements while text layers keep arriving must keep the page the search began + /// on, not re-anchor on whichever page an earlier automatic flash happened to show. + #[test] + fn rapid_query_edits_interleaved_with_text_results_keep_the_start_anchor() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sam".into(), ..Default::default() }); + v.rerun_find(); + assert!(v.find.as_ref().unwrap().current.is_none()); + + let edits = [("samp", 2, "Page three sample", 2), ("sampl", 1, "Page two sample", 1), ("sample", 0, "Page one sample", 0)]; + for (query, page, text, shown) in edits { + v.find.as_mut().unwrap().query = query.into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader started from"); + assert!(!f.follow_ended); + v.texts.insert(page, Arc::new(find_test_text(text))); + v.refresh_find_page(page); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, shown, "the late page follows the automatic anchor {shown}"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(shown))); + assert!(!f.follow_ended, "the automatic flash itself does not end following"); + } + assert!(v.back.is_empty(), "automatic search updates do not record history"); + } + #[test] fn text_layers_evict_far_pages_but_keep_visible_and_matches() { let info = thumbnail_info(100); @@ -6977,6 +7573,7 @@ trailer << /Root 1 0 R >> delta: vec2(0.0, -100_000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); // Well past egui's longest scroll animation (about 0.3 s). h.run_steps(40); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b7dd6b3ed8..b977112460 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,7 +1,7 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; -use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; @@ -27,6 +27,8 @@ const TAB_STRIP: f32 = 38.0; const TAB_HEIGHT: f32 = 30.0; const STRIP_BUTTON: f32 = 28.0; +/// Draw the window's tab strip: home and open controls, shrinkable and scrollable document tabs with drag +/// reordering, and platform window controls. pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and @@ -39,7 +41,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); + let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); } @@ -93,7 +95,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let cap = tab_cap(&natural, tabs_width, gap, TAB_MIN_WIDTH); // Where the tabs were scrolled to last frame, and how far they can go. - let scroll_id = ui.id().with("tab_scroll"); + let scroll_id = ui.scope_id().with("tab_scroll"); let (offset, max_offset) = ui.data(|d| d.get_temp::<(f32, f32)>(scroll_id)).unwrap_or((0.0, 0.0)); let mut scroll_to = None; if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) { @@ -103,7 +105,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // the strip jumping to it. let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing()); let changed = ui.data_mut(|data| { - let id = ui.id().with("active_tab"); + let id = ui.scope_id().with("active_tab"); let changed = data.get_temp::<(Option, usize, bool)>(id) != Some(state); data.insert_temp(id, state); changed @@ -132,7 +134,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let file = (name != doc.name).then_some(doc.name.as_str()); let response = tab( ui, - ui.id().with(("document-tab", app.views[i].id)), + ui.scope_id().with(("document-tab", app.views[i].id)), &t, "file-text", &name, @@ -214,7 +216,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut close = None; let response = tab( ui, - ui.id().with("combine-tab"), + ui.scope_id().with("combine-tab"), &t, "files", tl!("Combine files"), @@ -240,7 +242,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); let max = (out.content_size.x - out.inner_rect.width()).max(0.0); - ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max))); + ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.clamped_offset().x, max))); // A tab dragged to either edge of a strip that scrolls brings the hidden tabs in. if let Some(x) = drag_x && max > 0.0 @@ -253,7 +255,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 0.0 }; if step != 0.0 { - ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max))); + ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.clamped_offset().x + step).clamp(0.0, max))); ui.ctx().request_repaint(); } } @@ -384,7 +386,7 @@ fn tab_name(name: &str) -> String { /// The width a tab takes when nothing has to shrink. fn tab_natural_width(ui: &egui::Ui, t: &Tokens, name: &str) -> f32 { - let label = crate::bidi::visual(&tab_name(name)).into_owned(); + let label = tab_name(name); ui.fonts_mut(|f| f.layout_no_wrap(label, theme::regular(13.0), t.text).size().x) + TAB_CHROME } @@ -412,6 +414,8 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option { None } +/// Draw one tab with its icon, elided label, close button and unsaved dot; records a close request and +/// returns the tab response for drag handling. #[allow(clippy::too_many_arguments)] fn tab( ui: &mut egui::Ui, @@ -433,13 +437,13 @@ fn tab( // A shrunk tab shows as much of its name as fits (the tooltip has all of it). if let Some(cap) = cap { let room = (cap - TAB_CHROME).max(0.0); - label = ui.fonts_mut(|f| { - crate::panels::ellipsized_prefix(&label, |s| f.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= room) - }); + label = crate::panels::ellipsized_prefix(&label, |s| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), t.text).size().x <= room); } - // Painted text only: the accessibility name below keeps the logical order. - let label = crate::bidi::visual(&label).into_owned(); - let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); + // Logical text, laid out explicitly left to right: the fork shapes and reorders it once, + // and an Arabic file name keeps its extension at the visual end. AccessKit names stay logical. + let label_color = if active { t.text } else { t.text_muted }; + let label_galley = crate::bidi::layout_ltr(ui, label.clone(), font.clone(), label_color); + let text_w = label_galley.size().x; let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap)); // Full strip height so the active tab sits on the mode bar instead of floating in the title bar. let height = ui.available_height().max(height); @@ -463,7 +467,7 @@ fn tab( 15.0, if active { t.accent } else { t.text_muted }, ); - ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted }); + ui.painter().galley(rect.min + vec2(28.0, (rect.height() - label_galley.size().y) / 2.0), label_galley.clone(), label_color); }); let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0)); let x = ui.interact(x_rect, id.with("close"), Sense::click()); @@ -479,14 +483,10 @@ fn tab( *close = Some(index); } let shown = match file { - Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))), - None => crate::bidi::visual(name), + Some(file) => format!("{name}\n{file}"), + None => name.to_string(), }; - theme::hand(resp).on_hover_text(if dirty { - crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) - } else { - shown.into_owned() - }) + theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown }) } pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -637,6 +637,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the +/// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -675,7 +677,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { items.push(item(RightPanel::Tags, "tag", "Tags", None)); let view = &mut app.views[index]; let mut page_display = None; - let rail = Rail::new("rail") + let rail = Rail::new(egui::Id::unique("rail")) .edge(Edge::End) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { @@ -710,13 +712,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let id = egui::Id::new("zoom-input"); + let id = egui::Id::unique("zoom-input"); let draft = id.with("draft"); let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); let edit = egui::TextEdit::singleline(&mut typed) .id(id) .desired_width(40.0) - .horizontal_align(Align::Center) + .align(egui::Align2::CENTER_TOP) .font(theme::medium(11.0)) .text_color(t.text_muted) .frame(egui::Frame::NONE) @@ -782,7 +784,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let edit = egui::TextEdit::singleline(&mut view.page_input) .id(page_input_id) .desired_width(34.0) - .horizontal_align(Align::Center) + .align(egui::Align2::CENTER_TOP) .font(theme::medium(12.0)) .margin(vec2(2.0, 4.0)); let r = egui::Frame::NONE @@ -791,7 +793,8 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .corner_radius(CornerRadius::same(5)) .show(ui, |ui| ui.add(edit)) .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")) + .accessible_name(tl!("Current page")); if r.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Enter)) { // A page label first (logical page numbers, as Acrobat), then a number. diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index ee3b03f698..f66ec0b843 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -614,7 +614,14 @@ pub(crate) fn grid( let over_grid = ui.rect_contains_pointer(viewport); let mut width = viewport.width(); let mut focused_card = None; - let output = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]).show_viewport(ui, |ui, clip| { + // A zoom in the previous frame asks for a new scroll position. 0.37 keeps `State`'s offset + // private, so the request is applied through the builder and consumed here. + let pending_offset = ctx.data_mut(|d| d.remove_temp::(pending_offset_id())); + let mut grid = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]); + if let Some(offset) = pending_offset { + grid = grid.vertical_scroll_offset(offset.y); + } + grid.show_viewport(ui, |ui, clip| { // Dragging near the top or bottom edge scrolls. if drag.is_some() && let Some(p) = pointer @@ -792,12 +799,10 @@ pub(crate) fn grid( let cols = columns_in(width, cell); let content = TOP + places.len().div_ceil(cols) as f32 * cell.y; let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0); - // Set as the scroll position itself, before the next frame draws anything: no + // Applied by the next frame's scroll area, before anything is drawn there: no // jump, and no scroll still animating (to a card revealed by the keys, say) can - // carry it elsewhere. - let mut state = egui::scroll_area::State::default(); - state.offset = vec2(0.0, y); - state.store(&ctx, output.id); + // carry it elsewhere. The request is consumed once. + ctx.data_mut(|d| d.insert_temp(pending_offset_id(), vec2(0.0, y))); } ctx.request_repaint(); } @@ -806,6 +811,11 @@ pub(crate) fn grid( action } +/// The scroll position a zoom asked the next frame's grid to start at. +fn pending_offset_id() -> egui::Id { + egui::Id::unique("pdfcraft-combine-grid-offset") +} + /// One card's place and what it shows. struct Card { /// The file, by its place in the list. @@ -826,8 +836,8 @@ struct Card { impl Card { fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { - None => ui.id().with(("combine-card", f.id)), - Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)), + None => ui.scope_id().with(("combine-card", f.id)), + Some((nth, ..)) => ui.scope_id().with(("combine-page", f.id, nth)), } } @@ -845,7 +855,7 @@ impl Card { }; // As the organize grid's pages: a check box that is on when selected; and, for screen // readers, selected as items in a list are. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, self.selected, &label)); let pages = match self.page { // For screen readers: which page of how many. Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]), @@ -969,9 +979,11 @@ impl Card { } None => (theme::medium(12.5), t.text, name_y), }; - let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w; - let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned(); - painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour); + let fits = |s: &str| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w; + let shown = fit_name(&f.name, fits); + // A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`. + let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour); + painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, colour); let note = if self.page.is_none() && !f.range.trim().is_empty() { match &self.check { Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)), @@ -1143,10 +1155,10 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { // No larger than the GPU takes, or the render would be refused. let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); let thumbs = &mut app.combine_thumbs; - let modal = egui::Modal::new(egui::Id::new("combine-preview")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| { ui.set_width(width); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual(&f.name)).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { close = true; diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 62d9163154..e7f7f26a19 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -742,7 +742,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.close(); } for (i, name) in &open_docs { - if ui.button(crate::bidi::visual(name).as_ref()).clicked() { + if ui.button(name.as_str()).clicked() { app.add_open_documents(&[*i]); ui.close(); } @@ -921,7 +921,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default())); } let mut choice: Option = None; - let modal = egui::Modal::new(egui::Id::new("combine-skip")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-skip")).show(ctx, |ui| { ui.set_width(460.0); ui.horizontal(|ui| { ui.add(icons::image("triangle-alert", 22.0, ERROR)); @@ -932,7 +932,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) { ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(format!("• {}", crate::bidi::visual(name))).strong()); + ui.label(egui::RichText::new(format!("• {name}")).strong()); ui.label(egui::RichText::new(*why).color(t.text_muted)); }); } @@ -978,7 +978,7 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { }; let batch = files.len() > 1; let (mut submit, mut cancel) = (false, false); - let area = egui::Area::new(egui::Id::new("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { @@ -1189,7 +1189,7 @@ fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32 let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)).frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { app.combine_tab.zoom_request = Some(1.0); } @@ -1347,7 +1347,7 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b None => "", }; let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") }; - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &a11y)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a11y)); let being_dragged = egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|p| p.0 == key); if being_dragged || response.is_pointer_button_down_on() { ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed); @@ -1395,6 +1395,8 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b event } +/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata +/// and per-row warnings. #[allow(clippy::too_many_arguments)] fn table_body( app: &mut PdfCraftApp, @@ -1410,7 +1412,7 @@ fn table_body( let order = app.combine_columns.order; // The heading being dragged follows the pointer. if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::(ui.ctx()), ui.ctx().pointer_latest_pos()) { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("combine-heading-drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("combine-heading-drag"))); let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text); let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0)); painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); @@ -1454,7 +1456,7 @@ fn table_body( row.set_selected(rows.selected.get(i).copied().unwrap_or(false)); // Drag handle. row.col(|ui| { - let id = ui.id().with(("combine-drag", f.id)); + let id = ui.scope_id().with(("combine-drag", f.id)); ui.dnd_drag_source(id, i, |ui| { let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover()); grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint }); @@ -1467,7 +1469,8 @@ fn table_body( row.col(|ui| match kind { SortKey::Name => { ui.add(icons::image("file-text", 16.0, t.icon)); - ui.add(egui::Label::new(crate::bidi::visual(&f.name).as_ref()).truncate().selectable(false)); + // A file name is file/path chrome: explicitly LTR, extension last. + crate::widgets::label_ltr_truncated(ui, f.name.clone(), theme::regular(13.0), t.text); } // The range field and how many pages it takes. SortKey::Pages => { @@ -1581,7 +1584,7 @@ fn table_body( let r = response.rect; let y = if from > i { r.top() } else { r.bottom() }; if from != i { - response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("combine-drop-line"))).hline( + response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("combine-drop-line"))).hline( r.x_range(), y, Stroke::new(2.0, t.accent), diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 928d5cbf6b..bf9b5fe72c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1621,12 +1621,12 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, anchor = anchor.clamp(viewport.min, viewport.max); } let pos = pos2(anchor.x + 12.0, anchor.y); - let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(prefs.author.clone()).font(theme::semibold(13.0))); ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint)); }); ui.add_space(6.0); @@ -1636,8 +1636,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => tl!("Replacement text"), _ => tl!("Add a comment"), }; - let edit = - ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")); + let edit = ui + .add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")) + // 0.37 kittest rejects an unnamed text input; the hint is its logical name. + .accessible_name(hint); if c.focus { edit.request_focus(); c.focus = false; @@ -2056,8 +2058,9 @@ pub enum CanvasAction { /// How many custom colours the "Recent colours" row keeps. const RECENT_CUSTOM: usize = 5; +/// Return the stable egui id for the recent custom-colour palette. fn recent_custom_id() -> egui::Id { - egui::Id::new("pdfcraft-recent-custom-colours") + egui::Id::unique("pdfcraft-recent-custom-colours") } /// Custom colours picked with "Other colour…" this session, newest first (#1193). @@ -2091,7 +2094,7 @@ fn rgb_of(c: Color32) -> Rgb { /// One round swatch; `on` rings it as the current colour. fn swatch(ui: &mut egui::Ui, c: Rgb, on: bool, tooltip: &str) -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tooltip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tooltip)); ui.painter().circle_filled(r.center(), 9.0, color32(c)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40))); if on || resp.hovered() { @@ -2113,7 +2116,7 @@ fn other_colour_swatch(ui: &mut egui::Ui, open: bool) -> egui::Response { ui.painter().circle_stroke(c, 11.0, Stroke::new(1.5, SELECT_BLUE)); } let label = tl!("Other colour…"); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, open, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, open, label)); resp.on_hover_text(label) } @@ -2202,7 +2205,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let resp = resp.on_hover_text(tl!("Colour")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { if let Some(c) = swatch_grid(ui, Some(style.color)) { @@ -2266,7 +2269,7 @@ fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { Some(_) => tl!("Fill colour"), None => tl!("Fill colour: none"), }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); resp.on_hover_text(label) } @@ -2587,7 +2590,7 @@ mod tests { h.run(); h.get_by_label("Page").click_secondary(); h.run(); - h.get_by_label("Colour ⏵").click(); + h.get_by_label("Colour").click(); h.run(); h.get_by_label("Other colour…").click(); h.run(); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 627385b228..54ca9e5152 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -124,7 +124,9 @@ pub(crate) fn show( let mut edit = None; // Search (the header's magnifier toggles it). if let Some(q) = view.comments.search.as_mut() { - let r = ui.add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")); + let r = ui + .add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")) + .accessible_name(tl!("Search comments")); if view.comments.search_focus { r.request_focus(); view.comments.search_focus = false; @@ -133,9 +135,14 @@ pub(crate) fn show( } // "Add a comment": a sticky note on the current page, near its top-right corner. if allowed { - let r = ui.add( - crate::widgets::line(&mut view.comments.add_box).hint_text(tl!("Add a comment")).desired_width(f32::INFINITY).id_salt("comment-add-box"), - ); + let r = ui + .add( + crate::widgets::line(&mut view.comments.add_box) + .hint_text(tl!("Add a comment")) + .desired_width(f32::INFINITY) + .id_salt("comment-add-box"), + ) + .accessible_name(tl!("Add a comment")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() { let page = view.current.min(info.pages.len().saturating_sub(1)); if let Some(p) = info.pages.get(page) { @@ -238,9 +245,7 @@ pub(crate) fn show( if g.is_some() && g != group { ui.add_space(6.0); let title = g.clone().unwrap_or_default(); - // Catalog labels are already in display order; an author's name is put in it here. - let shown = if sort == SortBy::Author && a.author.is_some() { crate::bidi::visual_auto(&title) } else { tl!(&title).into() }; - ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted)); + ui.label(egui::RichText::new(tl!(&title)).font(theme::semibold(12.5)).color(t.text_muted)); group = g; } } else if a.page != page { @@ -287,6 +292,8 @@ pub(crate) fn show( edit } +/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment +/// actions; returns an edit to apply. #[allow(clippy::too_many_arguments)] fn card( ui: &mut egui::Ui, @@ -322,7 +329,8 @@ fn card( let mut above = egui::Rect::NOTHING; let head = ui.horizontal(|ui| { above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); - let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto); + // The catalogue and author names stay logical: egui lays out RTL text itself. + let author = a.author.as_deref().map_or_else(|| tl!("Unknown author"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -402,7 +410,7 @@ fn card( ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { - let author = r.author.as_deref().map_or_else(|| tl!("Reply").into(), crate::bidi::visual_auto); + let author = r.author.as_deref().map_or_else(|| tl!("Reply"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.0))); if let Some(m) = &r.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -461,7 +469,7 @@ fn card( // clickable everywhere. let hit = if selected { frame.inner } else { rect }; let click = ui - .interact(hit, ui.id().with(("card", key)), Sense::click()) + .interact(hit, ui.scope_id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { @@ -472,7 +480,7 @@ fn card( if click.double_clicked() && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } - let menu_id = ui.id().with(("card-menu", key)); + let menu_id = ui.scope_id().with(("card-menu", key)); if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); } @@ -636,8 +644,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.separator(); ui.label(egui::RichText::new(tl!("Author")).small()); for a in &authors { - let name = crate::bidi::visual_auto(a); - toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { &name }); + toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a.as_str() }); } ui.separator(); ui.label(egui::RichText::new(tl!("Status")).small()); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 0d86f7dd8f..0da3b235c8 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -91,6 +91,11 @@ pub struct ContentView { } impl ContentView { + /// Forget an in-progress pointer grab for content editing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.grab = None; + } + pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { self.draft.take().and_then(|d| finish(self, d, added)) } @@ -585,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). - egui::Area::new(egui::Id::new(("added-text", view.id.0))) + egui::Area::new(egui::Id::unique(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(at) @@ -619,7 +624,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .font(font.clone()) .desired_width(width) .desired_rows(1) - .horizontal_align(halign) + .align(egui::Align2::new(halign, egui::Align::TOP)) + // Keep the page editor's original inset; the theme's larger button padding + // otherwise shifts narrow right-aligned boxes away from their text edge. + .margin(egui::Margin::symmetric(4, 2)) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) @@ -627,7 +635,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad if letter_spacing != 0.0 { edit = edit.layouter(&mut spaced); } - let resp = ui.add(edit); + let resp = ui.add(edit).accessible_name(tl!("Added text")); if t.focus { resp.request_focus(); t.focus = false; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 444afd73d5..db6e2445db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -53,21 +53,14 @@ use serde_json::{Value, json}; pub type Reply = Result; /// Seconds to wait for frames (input to be handled, a screenshot to be taken) before reporting -/// that the window is not being drawn. While a window is hidden, minimized or fully covered, -/// eframe keeps calling `logic` but runs no egui pass, so input and screenshots wait. +/// that the window is not being drawn. `App::logic` also runs while a window is hidden, +/// minimized or fully covered, where egui's own input clock does not advance; every deadline +/// here uses a monotonic `web_time::Instant` so hidden logic cannot stall or restart it. const NOT_DRAWN_TIMEOUT: f64 = 5.0; -/// Wall-clock seconds (egui's own clock stops while the window is not drawn). -fn now_secs() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() - } - #[cfg(target_arch = "wasm32")] - { - js_sys::Date::now() / 1000.0 - } +/// Return the deadline used to reject control requests that wait for a drawn window. +fn timeout() -> std::time::Duration { + std::time::Duration::from_secs_f64(NOT_DRAWN_TIMEOUT) } fn not_drawn(what: &str) -> String { @@ -76,8 +69,23 @@ fn not_drawn(what: &str) -> String { ) } +/// Return the actionable error text for a control request whose window was not drawn in time. +fn still_starting() -> String { + format!("the app was still starting after {NOT_DRAWN_TIMEOUT:.0} s; the control request was not applied, retry when the window is drawn") +} + +/// The app finished starting, but only after this request's one admission deadline had already +/// passed. Report that instead of claiming the app is still starting. +fn startup_deadline_expired() -> String { + format!( + "the app finished starting after this control request's {NOT_DRAWN_TIMEOUT:.0} s deadline; the request was not applied, retry now that the window is drawn" + ) +} + /// One request from a client, answered on `reply`. pub struct ControlRequest { + /// Admission time on the sending thread, including time waiting for the next app tick. + pub since: web_time::Instant, pub method: String, pub params: Value, pub reply: Sender, @@ -94,7 +102,7 @@ impl ControlClient { /// Queue a request; the reply arrives on the returned receiver once the app has handled it. pub fn send(&self, method: &str, params: Value) -> Receiver { let (reply, rx) = channel(); - let req = ControlRequest { method: method.into(), params, reply }; + let req = ControlRequest { since: web_time::Instant::now(), method: method.into(), params, reply }; if let Err(e) = self.tx.send(req) { let _ = e.0.reply.send(Err("the app has closed".into())); } @@ -109,12 +117,99 @@ struct Shared { nodes: HashMap, root: Option, focus: Option, - /// Event batches to inject, one batch per frame. - inject: VecDeque>, - /// Real egui passes so far (counted in `output_hook`, which `logic`-only calls never reach). + /// Queued synthetic-input requests, each with one event batch per UI pass. + inject: VecDeque, + /// Accepted (not discarded) egui passes, counted in `output_hook`. passes: u64, + /// The document/focus generation the app last admitted input for. + epoch: u64, + /// Input requests whose queued events became stale before a UI pass consumed them. + dropped: Vec, + next_batch: u64, + /// A batch admitted before its deadline may finish this pass. Expiry runs before the next + /// input hook (or in hidden logic), never after egui has already calculated its interaction. + in_pass: bool, + expired: Vec, + cleanup: Vec, + cancel_pointer: bool, + cancel_gestures: bool, } +/// One queued synthetic-input request: its batches are injected one per UI pass. +struct RequestBatches { + request: u64, + /// The document/dialog/focus generation the request was admitted for. + epoch: u64, + /// The first batch was injected, so a focus/dialog change does not reject its own remaining + /// batches. The admission deadline still cancels a partially delivered gesture. + started: bool, + since: web_time::Instant, + /// Inverse presses still held by this request. Pointer entries are ownership markers for + /// cancellation only: replaying one on timeout would click or drop the abandoned gesture. + release: Vec, + batches: VecDeque>, +} + +/// Track only presses that reached egui and have not yet been released. A pointer release is +/// retained as an ownership marker, never as a timeout event. +fn note_pressed(release: &mut Vec, events: &[egui::Event]) { + for event in events { + match event { + egui::Event::Key { key, pressed: true, .. } + if !release.iter().any(|event| matches!(event, egui::Event::Key { key: seen, pressed: false, .. } if seen == key)) => + { + release.push(egui::Event::Key { key: *key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::PointerButton { pos, button, pressed: true, .. } + if !release + .iter() + .any(|event| matches!(event, egui::Event::PointerButton { button: seen, pressed: false, .. } if seen == button)) => + { + release.push(egui::Event::PointerButton { pos: *pos, button: *button, pressed: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::ModifiersChanged(modifiers) + if *modifiers != egui::Modifiers::NONE && !release.contains(&egui::Event::ModifiersChanged(egui::Modifiers::NONE)) => + { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + egui::Event::Key { key, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::Key { key: seen, .. } if seen == key)); + } + egui::Event::PointerButton { button, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::PointerButton { button: seen, .. } if seen == button)); + } + egui::Event::ModifiersChanged(modifiers) if *modifiers == egui::Modifiers::NONE => { + release.retain(|event| !matches!(event, egui::Event::ModifiersChanged(_))); + } + _ => {} + } + } +} + +/// Cancel expired requests before egui computes interaction. Hidden logic calls this too, so +/// replies do not wait for a visible frame. Never complete an abandoned pointer gesture. +fn expire_input(shared: &mut Shared, now: web_time::Instant) { + shared.inject.retain_mut(|request| { + if now.duration_since(request.since) <= timeout() { + return true; + } + shared.expired.push(request.request); + for event in request.release.drain(..) { + if matches!(event, egui::Event::PointerButton { .. }) { + shared.cancel_pointer = true; + shared.cancel_gestures = true; + } else { + shared.cleanup.push(event); + } + } + false + }); +} + +/// Synthetic input is bounded like screenshots: a local authenticated client must not be able to +/// queue unbounded batches (one frame each) and hold the control channel's memory for ever. +const MAX_QUEUED_BATCHES: usize = 512; + /// The egui plugin half of the control channel. pub struct ControlPlugin { shared: Arc>, @@ -125,19 +220,71 @@ impl egui::Plugin for ControlPlugin { "pdfcraft-control" } + /// Inject admitted control batches into the current pass, dropping stale requests, applying cancellations + /// and scheduling repaints. fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) { let Ok(mut s) = self.shared.lock() else { return }; - if let Some(batch) = s.inject.pop_front() { - input.events.extend(batch); + expire_input(&mut s, web_time::Instant::now()); + if std::mem::take(&mut s.cancel_pointer) { + // input_hook precedes Context::begin_pass. stop_dragging sets the OLD snapshot's + // drag_stopped; begin_pass replaces that snapshot without a release/click/drop. + ctx.input_mut(|i| i.pointer = Default::default()); + ctx.stop_dragging(); + egui::DragAndDrop::clear_payload(ctx); + } + input.events.append(&mut s.cleanup); + s.in_pass = true; + let epoch = s.epoch; + let Some(front) = s.inject.front_mut() else { return }; + if !front.started { + if front.epoch != epoch { + // The document, dialog or focus changed after this request was admitted, before + // any of its input landed. Drop only this request; later requests are unrelated. + let request = front.request; + s.inject.pop_front(); + s.dropped.push(request); + if !s.inject.is_empty() { + ctx.request_repaint(); + } + return; + } + front.started = true; + } + // A non-expired request's own earlier batch may have changed focus or opened a dialog. + // Let it finish normally; expiry above cancels held pointer state instead of completing + // an abandoned click/drop. + let events = front.batches.pop_front(); + if let Some(events) = &events { + // Remember the inverse of what this request presses as it is applied, so a timeout + // after this batch can still release exactly those inputs. + note_pressed(&mut front.release, events); + } + let done = front.batches.is_empty(); + if done { + s.inject.pop_front(); + } + if let Some(events) = events { + input.events.extend(events); } if !s.inject.is_empty() { ctx.request_repaint(); } } - fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) { + /// Record presented passes and the accesskit tree and focus after a pass, so control replies observe only + /// drawn frames. + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { let Ok(mut s) = self.shared.lock() else { return }; - s.passes += 1; + s.in_pass = false; + // A discarded pass is layout work, not a frame the agent can observe. Only count passes + // whose output is actually presented, so a request cannot be answered early. The last + // pass of a frame is presented even when its own discard request was refused because + // `max_passes` is reached; egui still reports that request in the output. + let max_passes = ctx.options(|o| o.max_passes.get()); + let final_pass = output.platform_output.num_completed_passes >= max_passes; + if output.platform_output.request_discard_reasons.is_empty() || final_pass { + s.passes += 1; + } let Some(update) = &output.platform_output.accesskit_update else { return }; s.nodes = update.nodes.iter().cloned().collect(); s.root = update.tree.as_ref().map(|t| t.root); @@ -145,30 +292,271 @@ impl egui::Plugin for ControlPlugin { } } +/// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. +const MAX_PENDING_SCREENSHOTS: usize = 4; +const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// One image's encode working copies are bounded too: the RGBA buffer fed to the PNG encoder, +/// the encoded PNG, and the base64 string in the reply. They are made one capture at a time, +/// after the retained image budget above has been checked. Peak encoding memory is therefore at +/// most the retained aggregate plus one crop, one PNG and one base64 string; it is not hidden +/// behind the retained-image limit. +const MAX_ENCODED_BYTES: usize = MAX_IMAGE_BYTES; +const MAX_BASE64_BYTES: usize = 192 * 1024 * 1024; + +/// Replace one pending capture's reservation with `bytes`, keeping the other reservations. +/// +/// The drain retries captures one at a time: using a single total taken before the loop would +/// let several retries each see room that no longer exists together, and the aggregate would +/// grow past [`MAX_TOTAL_BYTES`]. +fn retried_reservation(total: usize, old: usize, bytes: usize) -> Option { + let without_this = total.saturating_sub(old); + let new_total = without_this.saturating_add(bytes); + (bytes <= MAX_IMAGE_BYTES && new_total <= MAX_TOTAL_BYTES).then_some(new_total) +} + +/// The window content a screenshot request was admitted and issued for. +/// +/// Only the viewport's local size and scale matter: the window's position on the monitor does not +/// change a single captured pixel, and keyboard focus is not part of the captured content. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Geometry { + size: egui::Vec2, + pixels_per_point: f32, + /// Active document and open dialog/prompt identity, never the focused widget. + epoch: u64, +} + +impl Geometry { + /// Capture the current viewport size and effective pixels-per-point for the request epoch. + fn current(ctx: &egui::Context, epoch: u64) -> Self { + // Viewport info is refreshed by `run_logic`; the last UI pass's `viewport_rect` can be + // stale while the window is hidden. Fall back to it only when the host cannot say. Only + // its size is kept: `inner_rect` is in monitor space and its origin may move. + let (rect, native) = ctx.input(|i| { + let viewport = i.viewport(); + (viewport.inner_rect.unwrap_or_else(|| i.viewport_rect()), viewport.native_pixels_per_point) + }); + let native = native.or_else(|| ctx.native_pixels_per_point()).unwrap_or(1.0); + Self { size: rect.size(), pixels_per_point: native * ctx.zoom_factor(), epoch } + } + + /// Checked bytes an image of this geometry needs, before the capture is issued. + fn image_bytes(self) -> Option { + let width = (self.size.x * self.pixels_per_point).ceil(); + let height = (self.size.y * self.pixels_per_point).ceil(); + if !(width.is_finite() && height.is_finite()) || width <= 0.0 || height <= 0.0 { + return None; + } + checked_image_bytes(width as usize, height as usize) + } + + /// Whether `region` is a non-empty rectangle inside this viewport, in logical points. + fn contains_region(self, region: egui::Rect) -> bool { + region.min.x.is_finite() + && region.min.y.is_finite() + && region.max.x.is_finite() + && region.max.y.is_finite() + && region.min.x >= 0.0 + && region.min.y >= 0.0 + && region.max.x > region.min.x + && region.max.y > region.min.y + && region.max.x <= self.size.x + && region.max.y <= self.size.y + } +} + +/// Return the byte size of an RGBA image of the given dimensions, or `None` when the multiplication +/// overflows. +fn checked_image_bytes(width: usize, height: usize) -> Option { + width.checked_mul(height)?.checked_mul(core::mem::size_of::()) +} + enum Pending { - /// Answer after `frames` more frames (the injected input has been handled by then). + /// Answer after `frames` more accepted frames (the injected input has been handled by then). Frames { - /// Answer once this many egui passes have run (the injected input has been handled). + /// Answer once this many accepted UI passes have run. until_pass: u64, - since: f64, + /// The queued batches this request depends on; a stale one fails the request. + batches: Vec, + since: web_time::Instant, reply: Sender, value: Value, }, Screenshot { - tag: u64, + slot: craft_ui::capture::CaptureSlot, + /// The one-shot callback, kept until the settled pass that issues it. + callback: Option, region: Option, reply: Sender, - /// When it was requested (egui time, seconds). - since: f64, + since: web_time::Instant, + geometry: Geometry, + /// Bytes reserved in the aggregate budget for this request. + reserved: usize, + attempts: u8, }, } +/// A request that arrived while the app could not yet run a real UI pass (fonts not ready). +struct DeferredRequest { + request: ControlRequest, + /// When this request reached the control queue. One admission deadline runs from here through + /// startup, input injection, pending frames and screenshot issuance: dispatching it later must + /// not start a fresh 5 s in each phase, and a request that used up the deadline while the app + /// was starting is never applied against a later UI state. + since: web_time::Instant, +} + +/// A screenshot pending in the `finish_pending` pass, with ownership of its reply. +struct PendingCapture { + slot: craft_ui::capture::CaptureSlot, + callback: Option, + region: Option, + reply: Sender, + since: web_time::Instant, + geometry: Geometry, + reserved: usize, + attempts: u8, +} + +impl PendingCapture { + /// Convert a screenshot request into the pending form that later frames finish. + fn into_pending(self) -> Pending { + Pending::Screenshot { + slot: self.slot, + callback: self.callback, + region: self.region, + reply: self.reply, + since: self.since, + geometry: self.geometry, + reserved: self.reserved, + attempts: self.attempts, + } + } +} + +/// The state one `finish_pending` pass needs while resolving pending replies. +struct PendingFinisher { + now: web_time::Instant, + passes: u64, + current: Geometry, + in_pass: bool, + dropped: Vec, + expired: Vec, + reserved_total: usize, +} + +impl PendingFinisher { + /// Answer or keep one queued-input request, preserving its one arrival deadline. + fn resolve_frames(&self, until_pass: u64, batches: Vec, since: web_time::Instant, reply: Sender, value: Value) -> Option { + if batches.iter().any(|id| self.expired.contains(id)) { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + if batches.iter().any(|id| self.dropped.contains(id)) { + let _ = reply.send(Err("the document, dialog or focus changed before the input could be applied; nothing was done".into())); + return None; + } + if self.passes >= until_pass { + let _ = reply.send(Ok(value)); + return None; + } + if !self.in_pass && self.now.duration_since(since) > timeout() { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + Some(Pending::Frames { until_pass, batches, since, reply, value }) + } + + /// Give one reservation back to the running aggregate budget. + fn release_reservation(&mut self, reserved: usize) { + self.reserved_total = self.reserved_total.saturating_sub(reserved); + } + + /// Resolve one screenshot request, keeping it only while its capture is still pending. + fn finish_capture(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + // Completion cannot restart or bypass the admission deadline, even when the callback + // arrived before this delayed tick or a resize would retry it. + if self.now.duration_since(capture.since) > timeout() { + capture.slot.cancel(); + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(not_drawn("the screenshot"))); + return None; + } + match capture.slot.take() { + Some(Ok(image)) => self.finish_captured_image(ctx, capture, image), + Some(Err(error)) => { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(format!("the screenshot could not be captured: {error}"))); + None + } + None => Some(capture.into_pending()), + } + } + + /// Finish a screenshot whose callback produced an image. + fn finish_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture, image: Arc) -> Option { + if self.current.epoch != capture.geometry.epoch { + // The document/dialog changed: never save a capture of the previous context, and do + // not retry it into the new one. + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the document or dialog changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if self.current.size != capture.geometry.size || self.current.pixels_per_point != capture.geometry.pixels_per_point { + // A late completion after a resize/DPI change is never encoded: retry once with a + // fresh slot, else report a defined error. + return self.retry_captured_image(ctx, capture); + } + self.release_reservation(capture.reserved); + let _ = capture.reply.send(screenshot_png(&image, capture.region, capture.geometry)); + None + } + + /// Retry one resize/DPI-stale capture with the current geometry. + fn retry_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + if capture.attempts >= 1 { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window geometry changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if capture.region.as_ref().is_some_and(|region| !self.current.contains_region(*region)) { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the region must lie inside the window".into())); + return None; + } + let Some(bytes) = self.current.image_bytes() else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window has no capturable size".into())); + return None; + }; + let Some(new_total) = retried_reservation(self.reserved_total, capture.reserved, bytes) else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + return None; + }; + self.reserved_total = new_total; + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + Some(Pending::Screenshot { + slot, + callback: Some(callback), + region: capture.region, + reply: capture.reply, + since: capture.since, + geometry: self.current, + reserved: bytes, + attempts: capture.attempts + 1, + }) + } +} + /// The app half: the request queue and requests waiting for later frames. pub struct Control { rx: Receiver, shared: Arc>, pending: Vec, - next_tag: u64, + /// Requests admitted while the app cannot yet run a real UI pass (fonts not ready). + deferred: VecDeque, } /// Install the control channel on `ctx`: returns the app half and a client. @@ -177,112 +565,358 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { ctx.add_plugin(ControlPlugin { shared: shared.clone() }); ctx.enable_accesskit(); let (tx, rx) = channel(); - (Control { rx, shared, pending: Vec::new(), next_tag: 1 }, ControlClient { tx, ctx: ctx.clone() }) + (Control { rx, shared, pending: Vec::new(), deferred: VecDeque::new() }, ControlClient { tx, ctx: ctx.clone() }) } /// What a request needs from the app (implemented by `PdfCraftApp`). pub(crate) trait Host { + /// Return the UI-state snapshot reported by `ui.state`. fn state(&self) -> Value; + /// Run one registered command by id and return its reply. fn command(&mut self, id: &str) -> Reply; + /// Apply or reset the side panel layout from a control request. fn panel(&mut self, reset: bool, params: &Value) -> Reply; + /// List the commands available to the control channel. fn commands(&self) -> Value; + /// Set one application option from a control request. fn set(&mut self, key: &str, value: &str) -> Reply; + /// Open a document path requested through the control channel. fn open(&mut self, path: &str) -> Reply; + /// Request application exit; returns whether it closed without asking for confirmation. fn quit(&mut self, ctx: &egui::Context) -> bool; + + /// A generation that changes when the input target (document, dialog, focus) does. + /// + /// Synthetic input admitted for one generation must not be replayed into another. + fn input_epoch(&self) -> u64 { + 0 + } + + /// A generation for the captured content (document and dialog identity only). + /// + /// Screenshots do not depend on keyboard focus, so a field gaining focus must not discard a + /// pending capture. Hosts that do not distinguish the two reuse the input generation. + fn capture_epoch(&self) -> u64 { + self.input_epoch() + } + + /// Whether the app can apply a real UI pass yet (fonts installed, no early return). + fn accepts_input(&self) -> bool { + true + } + + /// Drop transient app-owned grabs before widgets observe the canceled pointer as up. + fn cancel_pointer_gestures(&mut self) {} } impl Control { - /// Handle new requests and finish waiting ones. Call at the start of every frame. + /// Handle new requests and finish waiting ones. Call at the start of every frame (`logic`). pub(crate) fn tick(&mut self, ctx: &egui::Context, host: &mut impl Host) { - self.finish_pending(ctx); - while let Ok(req) = self.rx.try_recv() { - match self.handle(ctx, host, &req.method, &req.params) { - Handled::Now(r) => { - let _ = req.reply.send(r); + self.set_epoch(host.input_epoch()); + self.finish_pending(ctx, host); + let ready = host.accepts_input(); + if !ready { + // Expiry applies while the app is still starting too: a request that waited through + // the startup gate must fail at the same deadline instead of running whenever fonts + // arrive. + let now = web_time::Instant::now(); + while self.deferred.front().is_some_and(|deferred| now.duration_since(deferred.since) > timeout()) { + if let Some(deferred) = self.deferred.pop_front() { + let _ = deferred.request.reply.send(Err(still_starting())); } - Handled::AfterFrames(frames, value) => { - let until_pass = self.passes() + u64::from(frames); - self.pending.push(Pending::Frames { until_pass, since: now_secs(), reply: req.reply, value }); - } - Handled::Screenshot(region) => { - let tag = self.next_tag; - self.next_tag += 1; - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(tag))); - self.pending.push(Pending::Screenshot { tag, region, reply: req.reply, since: now_secs() }); + } + } else { + // Deferred requests reached the queue before anything still in the channel, so they + // are dispatched first: one client's requests keep their arrival order. Each carries + // its own admission timestamp, so startup time counts against its deadline. + while let Some(deferred) = self.deferred.pop_front() { + if web_time::Instant::now().duration_since(deferred.since) > timeout() { + let _ = deferred.request.reply.send(Err(startup_deadline_expired())); + } else { + self.dispatch(ctx, host, deferred.request, deferred.since); } } } - if !self.pending.is_empty() { + while let Ok(req) = self.rx.try_recv() { + // One admission timestamp per request: queued input, pending frames and an admitted + // screenshot all measure their deadline from the sending thread, including time + // waiting in this channel, never from a later phase. + let since = req.since; + if web_time::Instant::now().duration_since(since) > timeout() { + let _ = req.reply.send(Err(if ready { startup_deadline_expired() } else { still_starting() })); + continue; + } + if !ready { + if self.deferred.len() < 32 { + self.deferred.push_back(DeferredRequest { request: req, since }); + } else { + let _ = req.reply.send(Err("the app is still starting; too many control requests queued".into())); + } + continue; + } + self.dispatch(ctx, host, req, since); + } + if !self.pending.is_empty() || !self.deferred.is_empty() { ctx.request_repaint(); + // A hidden or occluded window runs `logic` without a UI pass. Ask for one so queued + // input and screenshots can be applied; the screenshot command itself also paints. + // + // The shared fork's web host does not implement `RequestPaintWhileHidden` yet: a + // hidden browser tab still times out rather than falsely succeeding. Recorded as a + // pending fork prerequisite, not qualified here. + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } } } - fn finish_pending(&mut self, ctx: &egui::Context) { - let shots: Vec<(u64, Arc)> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|tag| (*tag, image.clone())) + /// Refresh the input-target generation before a pass consumes queued synthetic input. + /// + /// `raw_input_hook` runs before the plugin's `input_hook`, so a dialog opened by the + /// previous pass already invalidates a request that has not started yet, instead of a pass + /// later. Only that request is rejected; requests already started finish their batches. + pub(crate) fn set_epoch(&self, epoch: u64) { + if let Ok(mut s) = self.shared.lock() { + s.epoch = epoch; + } + } + + /// Issue admitted captures after a settled UI pass. Call at the end of `App::ui`. + pub(crate) fn finish_frame(&mut self, ctx: &egui::Context, host: &mut impl Host) { + if !host.accepts_input() || ctx.will_discard() { + return; + } + let current = Geometry::current(ctx, host.capture_epoch()); + let mut reserved_total = self.reserved_bytes(); + let mut kept = Vec::new(); + for pending in self.pending.drain(..) { + let mut entry = pending; + if let Pending::Screenshot { slot, callback, reply, region, geometry, reserved, attempts, since } = &mut entry + && callback.is_some() + { + if web_time::Instant::now().duration_since(*since) > timeout() { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err(not_drawn("the screenshot"))); + continue; + } + if geometry.epoch != current.epoch { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the document or dialog changed before the screenshot could be issued; it was not saved".into())); + continue; + } + if *geometry != current { + // Resize, DPI or zoom between admission and issuance: retry once with the + // real geometry rather than cropping with stale numbers. + if *attempts >= 1 { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window geometry changed while the screenshot was being prepared; it was not saved".into())); + continue; } - _ => None, - }) - .collect() - }); - let ppp = ctx.pixels_per_point(); - let now = now_secs(); - let passes = self.passes(); + if region.as_ref().is_some_and(|region| !current.contains_region(*region)) { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the region must lie inside the window".into())); + continue; + } + let Some(bytes) = current.image_bytes() else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window has no capturable size".into())); + continue; + }; + let Some(new_total) = retried_reservation(reserved_total, *reserved, bytes) else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + continue; + }; + reserved_total = new_total; + slot.cancel(); + let (new_slot, new_callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + *slot = new_slot; + *callback = Some(new_callback); + *geometry = current; + *reserved = bytes; + *attempts += 1; + } + if let Some(callback) = callback.take() { + // The screenshot command itself requests a paint, including while hidden. + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + } + } + kept.push(entry); + } + self.pending = kept; + if !self.pending.is_empty() { + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + + /// `since` is the request's arrival time: the one admission deadline it was admitted under. + fn dispatch(&mut self, ctx: &egui::Context, host: &mut impl Host, req: ControlRequest, since: web_time::Instant) { + match self.handle(ctx, host, &req.method, &req.params, since) { + Handled::Now(r) => { + let _ = req.reply.send(r); + } + Handled::AfterFrames(frames, value) => { + let until_pass = self.passes() + u64::from(frames); + self.pending.push(Pending::Frames { until_pass, batches: Vec::new(), since, reply: req.reply, value }); + } + Handled::AfterInput { batches, frames, value } => { + let until_pass = self.passes() + u64::from(frames) + 1; + self.pending.push(Pending::Frames { until_pass, batches, since, reply: req.reply, value }); + } + Handled::Screenshot(region) => self.admit_screenshot(ctx, host, region, req.reply, since), + } + } + + /// Return the total screenshot bytes reserved by pending captures without overflowing. + fn reserved_bytes(&self) -> usize { + self.pending + .iter() + .filter_map(|p| if let Pending::Screenshot { reserved, .. } = p { Some(*reserved) } else { None }) + .fold(0usize, usize::saturating_add) + } + + /// Count the screenshot requests still waiting for a capture. + fn pending_screenshots(&self) -> usize { + self.pending.iter().filter(|p| matches!(p, Pending::Screenshot { .. })).count() + } + + /// Admit one screenshot request within the count, per-image and aggregate limits. + fn admit_screenshot( + &mut self, + ctx: &egui::Context, + host: &mut impl Host, + region: Option, + reply: Sender, + since: web_time::Instant, + ) { + if self.pending_screenshots() >= MAX_PENDING_SCREENSHOTS { + let _ = reply.send(Err(format!("too many screenshots are already pending (limit {MAX_PENDING_SCREENSHOTS})"))); + return; + } + let geometry = Geometry::current(ctx, host.capture_epoch()); + let Some(bytes) = geometry.image_bytes() else { + let _ = reply.send(Err("the window has no capturable size".into())); + return; + }; + if let Some(region) = region + && !geometry.contains_region(region) + { + let _ = reply.send(Err("the region must lie inside the window".into())); + return; + } + if bytes > MAX_IMAGE_BYTES { + let _ = reply.send(Err(format!("the screenshot would use {bytes} bytes, over the {MAX_IMAGE_BYTES}-byte image limit"))); + return; + } + if self.reserved_bytes().saturating_add(bytes) > MAX_TOTAL_BYTES { + let _ = reply.send(Err(format!("the screenshot would exceed the {MAX_TOTAL_BYTES}-byte aggregate screenshot budget"))); + return; + } + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + self.pending.push(Pending::Screenshot { slot, callback: Some(callback), region, reply, since, geometry, reserved: bytes, attempts: 0 }); + } + + /// Resolve completed frame and screenshot requests while retaining those that still need a pass or a + /// retry. + fn finish_pending(&mut self, ctx: &egui::Context, host: &mut impl Host) { + let now = web_time::Instant::now(); + let (dropped, expired, in_pass) = self.take_finished_shared(ctx, host, now); + let mut finisher = PendingFinisher { + now, + passes: self.passes(), + current: Geometry::current(ctx, host.capture_epoch()), + in_pass, + dropped, + expired, + reserved_total: self.reserved_bytes(), + }; let mut keep = Vec::new(); - for p in self.pending.drain(..) { - match p { - Pending::Frames { until_pass, reply, value, .. } if passes >= until_pass => { - let _ = reply.send(Ok(value)); + for pending in std::mem::take(&mut self.pending) { + let kept = match pending { + Pending::Frames { until_pass, batches, since, reply, value } => finisher.resolve_frames(until_pass, batches, since, reply, value), + Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts } => { + finisher.finish_capture(ctx, PendingCapture { slot, callback, region, reply, since, geometry, reserved, attempts }) } - Pending::Frames { since, reply, .. } if now - since > NOT_DRAWN_TIMEOUT => { - // The agent is told it failed, so it must not happen later either. - if let Ok(mut sh) = self.shared.lock() { - sh.inject.clear(); - } - let _ = reply.send(Err(not_drawn("handling the input"))); - } - p @ Pending::Frames { .. } => keep.push(p), - Pending::Screenshot { tag, region, reply, since } => match shots.iter().find(|(t, _)| *t == tag) { - Some((_, image)) => { - let _ = reply.send(screenshot_png(image, region, ppp)); - } - // The platform skips painting windows that are hidden, minimized or fully - // covered, so no screenshot ever arrives: say so instead of hanging. - None if now - since > NOT_DRAWN_TIMEOUT => { - let _ = reply.send(Err(not_drawn("the screenshot"))); - } - None => keep.push(Pending::Screenshot { tag, region, reply, since }), - }, + }; + if let Some(pending) = kept { + keep.push(pending); } } self.pending = keep; } + /// Expire queued input and collect the batches a pending reply must react to. + fn take_finished_shared(&self, ctx: &egui::Context, host: &mut impl Host, now: web_time::Instant) -> (Vec, Vec, bool) { + let (dropped, expired, in_pass) = self + .shared + .lock() + .map(|mut s| { + if !s.in_pass { + expire_input(&mut s, now); + } + if std::mem::take(&mut s.cancel_gestures) { + host.cancel_pointer_gestures(); + } + (std::mem::take(&mut s.dropped), std::mem::take(&mut s.expired), s.in_pass) + }) + .unwrap_or_default(); + if !expired.is_empty() { + ctx.request_repaint(); // Run the queued key/modifier cleanup and pointer cancellation. + } + (dropped, expired, in_pass) + } + fn passes(&self) -> u64 { self.shared.lock().map(|s| s.passes).unwrap_or(0) } - fn inject(&self, batches: Vec>) -> u32 { - let n = batches.len() as u32; - if let Ok(mut s) = self.shared.lock() { - s.inject.extend(batches); + /// Queue one request's event batches (one per UI pass) and return its request id. + /// + /// The request is stamped once, at admission: all of its batches share the input-target + /// generation and the caller's single arrival deadline (`since`), so a deferred startup wait + /// is not restarted here. A poisoned mutex or a full queue is an error, never a silently empty + /// queue that would let the caller answer `Ok` for input that never happened. + fn inject(&self, batches: Vec>, since: web_time::Instant) -> Result, String> { + let mut s = self.shared.lock().map_err(|_| "control state poisoned".to_string())?; + let queued: usize = s.inject.iter().map(|request| request.batches.len()).sum(); + if queued.saturating_add(batches.len()) > MAX_QUEUED_BATCHES { + return Err(format!("too many synthetic input batches are already queued (limit {MAX_QUEUED_BATCHES})")); } - // The last batch is handled during the frame it is injected into; answer one frame later. - n + 1 + s.next_batch += 1; + let id = s.next_batch; + let epoch = s.epoch; + s.inject.push_back(RequestBatches { request: id, epoch, started: false, since, release: Vec::new(), batches: batches.into() }); + Ok(vec![id]) } - fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled { + /// Queue synthetic input batches and reply only after the requested number of frames has been presented. + fn after_input(&self, batches: Vec>, value: Value, since: web_time::Instant) -> Result { + let frames = batches.len() as u32; + let batches = self.inject(batches, since)?; + Ok(Handled::AfterInput { batches, frames, value }) + } + + /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with + /// actionable errors. + fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value, since: web_time::Instant) -> Handled { let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}")); let r: Result = (|| match method { "ui.state" => { let mut state = host.state(); - let size = ctx.input(|i| i.viewport_rect().size()); - state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": ctx.pixels_per_point() }); + // Viewport info is fresh in `logic`; the last UI pass's rectangle can be stale. + let geometry = Geometry::current(ctx, host.capture_epoch()); + let size = geometry.size; + state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": geometry.pixels_per_point }); Ok(Handled::Now(Ok(state))) } "ui.commands" => Ok(Handled::Now(Ok(host.commands()))), @@ -299,8 +933,8 @@ impl Control { } "ui.open" => Ok(Handled::Now(host.open(str_param("path")?))), "ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))), - "ui.click" => self.click(p), - "ui.focus" => self.focus(p), + "ui.click" => self.click(p, since), + "ui.focus" => self.focus(p, since), "ui.resize" => { only_params(p, method, &["width", "height"])?; let dimension = |key: &str| -> Result { @@ -326,7 +960,7 @@ impl Control { let quitting = host.quit(ctx); Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting })))) } - "ui.drag" => self.drag(p), + "ui.drag" => self.drag(p, since), "ui.move" => { let point = |key: &str| -> Result { p.get(key) @@ -336,19 +970,18 @@ impl Control { .ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points")) }; let pos = egui::pos2(point("x")?, point("y")?); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::PointerMoved(pos)]]), json!({ "moved": [pos.x, pos.y] }))) + self.after_input(vec![vec![egui::Event::PointerMoved(pos)]], json!({ "moved": [pos.x, pos.y] }), since) } "ui.type" => { let text = str_param("text")?.to_string(); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true }))) + self.after_input(vec![vec![egui::Event::Text(text)]], json!({ "typed": true }), since) } "ui.key" => { let name = str_param("key")?; let key = egui::Key::from_name(name) .ok_or_else(|| format!("ui.key: unknown key {name:?} (egui key names: A, Enter, Escape, ArrowDown, F5, …)"))?; let modifiers = modifiers(p.get("modifiers"))?; - let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - Ok(Handled::AfterFrames(self.inject(vec![vec![ev(true)], vec![ev(false)]]), json!({ "key": name }))) + self.after_input(key_batches(key, modifiers), json!({ "key": name }), since) } "ui.screenshot" => { let region = match p.get("region") { @@ -397,7 +1030,7 @@ impl Control { } /// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving). - fn drag(&mut self, p: &Value) -> Result { + fn drag(&mut self, p: &Value, since: web_time::Instant) -> Result { let point = |k: &str| -> Result { match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::>()).as_deref() { Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32)) @@ -430,11 +1063,11 @@ impl Control { if modifiers != egui::Modifiers::NONE { frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); } - let n = self.inject(frames); - Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }))) + self.after_input(frames, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }), since) } - fn click(&mut self, p: &Value) -> Result { + /// Click at a point, or on an accesskit widget by id or unique enabled label. + fn click(&mut self, p: &Value, since: web_time::Instant) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { let point = |key: &str, n: f64| { Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points")) @@ -443,8 +1076,8 @@ impl Control { let which = pointer_button(p, "ui.click")?; let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE }; // Keep hover/layout warmup, then make an instantaneous click even on slow frames. - let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]); - return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] }))); + let frames = vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]; + return self.after_input(frames, json!({ "clicked": [x, y] }), since); } let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label").and_then(Value::as_str)) { @@ -480,11 +1113,12 @@ impl Control { let label = node.label().map(str::to_owned); let action = accesskit::ActionRequest { action: Action::Click, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "clicked": id.0.to_string(), "label": label }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "clicked": id.0.to_string(), "label": label }), since) } - fn focus(&mut self, p: &Value) -> Result { + /// Focus an accesskit widget by id or unique enabled label. + fn focus(&mut self, p: &Value, since: web_time::Instant) -> Result { only_params(p, "ui.focus", &["id", "label"])?; let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label")) { @@ -512,14 +1146,20 @@ impl Control { } let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "focus_requested": id.0.to_string() }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "focus_requested": id.0.to_string() }), since) } } enum Handled { Now(Reply), + /// Answer after this many accepted UI passes, without injecting input. AfterFrames(u32, Value), + AfterInput { + batches: Vec, + frames: u32, + value: Value, + }, Screenshot(Option), } @@ -584,6 +1224,23 @@ fn pointer_button(p: &Value, method: &str) -> Result Vec> { + let key_event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let mut press = vec![key_event(true)]; + if modifiers != egui::Modifiers::NONE { + press.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + let mut release = vec![key_event(false)]; + if modifiers != egui::Modifiers::NONE { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + vec![press, release] +} + fn modifiers(v: Option<&Value>) -> Result { let mut m = egui::Modifiers::NONE; for name in v.and_then(Value::as_array).into_iter().flatten() { @@ -616,19 +1273,47 @@ fn rect_param(v: &Value) -> Result { } /// Encode (a region of) a screenshot as PNG; the reply carries base64 data and the size. -fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32) -> Reply { +/// +/// The image scale is derived from the returned pixels and the logical viewport the capture was +/// admitted for, never from the current `Context`: egui-wgpu's surface fit may have shrunk the +/// surface (and a resize/DPI change has already been rejected before this call). +fn screenshot_png(image: &egui::ColorImage, region: Option, geometry: Geometry) -> Reply { let [w, h] = image.size; + let view = geometry.size; + if w == 0 || h == 0 { + return Err("the screenshot has no pixels".into()); + } + if !(view.x.is_finite() && view.y.is_finite()) || view.x <= 0.0 || view.y <= 0.0 { + return Err("the window has no capturable size".into()); + } + if region.is_some_and(|region| !geometry.contains_region(region)) { + return Err("the region must lie inside the window".into()); + } + let scale_x = w as f32 / view.x; + let scale_y = h as f32 / view.y; + // A fitted surface shrinks both sides by one factor; a different x/y scale means the window + // changed shape and this image cannot be cropped honestly. + if (scale_x - scale_y).abs() > 0.01 * scale_x.max(1.0) { + return Err("the screenshot's aspect ratio does not match the window".into()); + } let (x0, y0, x1, y1) = match region { Some(r) => { - let px = |v: f32, max: usize| ((v * ppp).round().max(0.0) as usize).min(max); - (px(r.min.x, w), px(r.min.y, h), px(r.max.x, w), px(r.max.y, h)) + let px = |v: f32, scale: f32, max: usize| ((v * scale).round().max(0.0) as usize).min(max); + (px(r.min.x, scale_x, w), px(r.min.y, scale_y, h), px(r.max.x, scale_x, w), px(r.max.y, scale_y, h)) } None => (0, 0, w, h), }; if x1 <= x0 || y1 <= y0 { return Err("the region is outside the window".into()); } - let mut rgba = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + let crop_bytes = (x1 - x0) + .checked_mul(y1 - y0) + .and_then(|pixels| pixels.checked_mul(core::mem::size_of::())) + .ok_or_else(|| "the screenshot region is too large".to_string())?; + if crop_bytes > MAX_ENCODED_BYTES { + return Err(format!("the screenshot region would need {crop_bytes} bytes to encode, over the {MAX_ENCODED_BYTES}-byte encode limit")); + } + let mut rgba = Vec::with_capacity(crop_bytes); for y in y0..y1 { for px in &image.pixels[y * w + x0..y * w + x1] { rgba.extend_from_slice(&px.to_srgba_unmultiplied()); @@ -642,11 +1327,69 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32 let mut writer = enc.write_header().map_err(|e| e.to_string())?; writer.write_image_data(&rgba).map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; + if out.len() > MAX_ENCODED_BYTES { + return Err(format!("the encoded screenshot is {} bytes, over the {MAX_ENCODED_BYTES}-byte encode limit", out.len())); + } + let encoded = out.len().div_ceil(3).checked_mul(4).ok_or_else(|| "the encoded screenshot is too large".to_string())?; + if encoded > MAX_BASE64_BYTES { + return Err(format!("the screenshot's base64 reply would be {encoded} bytes, over the {MAX_BASE64_BYTES}-byte limit")); + } use base64::Engine as _; - Ok(json!({ "png_base64": base64::engine::general_purpose::STANDARD.encode(out), "width": cw, "height": ch, "pixels_per_point": ppp })) + Ok(json!({ + "png_base64": base64::engine::general_purpose::STANDARD.encode(out), + "width": cw, + "height": ch, + "pixels_per_point": scale_x, + })) } impl Host for crate::PdfCraftApp { + /// Cancel any in-progress pointer gesture in every open view. + fn cancel_pointer_gestures(&mut self) { + for view in &mut self.views { + view.cancel_pointer_gestures(); + } + } + + /// The input target: active document, open dialog/prompt, palette and find state. + /// + /// Synthetic input admitted for one generation is never replayed into another document or + /// dialog. Only these identities are hashed, never text or document content. + fn input_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.capture_epoch().hash(&mut hasher); + // The focused widget is part of the input target: keys typed for one field must not land + // in another after focus moves. Only the id value is hashed, never text. + let focused = self.ctx.as_ref().and_then(|ctx| ctx.memory(|m| m.focused())).map(|id| id.value()); + focused.hash(&mut hasher); + hasher.finish() + } + + /// The captured content: active document and open dialog/prompt, never keyboard focus. + fn capture_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let doc = self.active.and_then(|i| self.views.get(i)).map(|v| v.id.0); + doc.hash(&mut hasher); + format!( + "{:?}/{:?}/{:?}/{:?}/{:?}", + self.dialog, + self.password_prompt.is_some(), + self.palette_open, + self.close_request.is_some(), + self.active.and_then(|i| self.views.get(i)).and_then(|v| v.find.as_ref().map(|_| true)), + ) + .hash(&mut hasher); + hasher.finish() + } + + /// Control requests wait for the first UI pass that installs fonts; a font-gated early + /// return cannot consume injected input or issue a screenshot. + fn accepts_input(&self) -> bool { + self.fonts_ready + } + fn panel(&mut self, reset: bool, params: &Value) -> Reply { crate::side_panels::command(self, reset, params) } @@ -1401,6 +2144,1009 @@ mod transport_tests { } } +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use egui::Plugin as _; + use egui_kittest::Harness; + + /// A request is answered after accepted frames only: a discarded layout pass is not a frame + /// the agent can see, so it must not advance the count. + #[test] + fn discarded_passes_are_not_counted_as_accepted_frames() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + let ctx = egui::Context::default(); + let mut accepted = egui::FullOutput::default(); + plugin.output_hook(&ctx, &mut accepted); + let mut discarded = egui::FullOutput::default(); + discarded.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("control test discard")); + plugin.output_hook(&ctx, &mut discarded); + assert_eq!(shared.lock().unwrap().passes, 1, "only the presented pass counts"); + } + + /// A frame that requests discard on every pass and hits `max_passes` still presents its + /// last pass: that presented pass must count, or `Pending::Frames` waits for a frame that + /// never arrives. This runs the real multi-pass loop rather than a hand-built FullOutput. + #[test] + fn a_final_capped_pass_is_counted_as_presented() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 2.try_into().expect("two is non-zero")); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| ui.ctx().request_discard("control test must discard")); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 2, "the cap was reached"); + assert!(!output.platform_output.request_discard_reasons.is_empty(), "the last pass still requested a discard"); + assert_eq!(shared.lock().unwrap().passes, 1, "the presented final pass counts once"); + } + + /// `max_passes` above two still counts a frame that settles after earlier discarded passes: + /// egui clears the previous pass's discard reasons at the start of each pass, so the merged + /// output of a presented final pass is not mistaken for a discarded one. + #[test] + fn a_frame_that_settles_before_a_higher_pass_cap_is_counted_once() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 3.try_into().expect("three is non-zero")); + let mut pass = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + pass += 1; + if pass < 3 { + ui.ctx().request_discard("control test must discard the first two passes"); + } + }); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 3, "the third pass settled at the higher cap"); + assert!(output.platform_output.request_discard_reasons.is_empty(), "earlier discard reasons do not survive the settling pass"); + assert_eq!(shared.lock().unwrap().passes, 1, "the one presented frame counts once"); + } + + /// `ui.key` delivers held modifiers through `ModifiersChanged`, because egui does not derive + /// `InputState.modifiers` from a key event's own field. Shift+Tab and ⌘Enter handlers read it. + #[test] + fn key_commands_deliver_modifier_state() { + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let batches = key_batches(egui::Key::Enter, held); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + assert_eq!( + batches, + vec![vec![egui::Event::ModifiersChanged(held), key(true)], vec![key(false), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]] + ); + let ctx = egui::Context::default(); + let mut press_seen = None; + ctx.run_ui(egui::RawInput { events: batches[0].clone(), ..Default::default() }, |ui| press_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + let mut release_seen = None; + ctx.run_ui(egui::RawInput { events: batches[1].clone(), ..Default::default() }, |ui| release_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + assert_eq!(press_seen, Some(held), "the press sees the modifiers it was sent with"); + assert_eq!(release_seen, Some(egui::Modifiers::NONE), "the modifier release follows the key-up"); + } + + /// A batch admitted for one document/focus generation must not apply after that generation + /// changes; the request is reported as stale instead of replaying the input. + #[test] + fn a_stale_synthetic_batch_is_dropped_and_reported() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + } + let mut raw = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut raw); + assert!(raw.events.is_empty(), "stale input must not reach the new document"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// A stale request is dropped alone. A request admitted later for the current generation + /// behind it still runs: one agent's outdated input must not fail another's. + #[test] + fn a_stale_request_does_not_drop_a_later_current_one() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 42, + epoch: 2, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 2.0))]]), + }); + } + let ctx = egui::Context::default(); + let mut first = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut first); + assert!(first.events.is_empty(), "the stale request is not replayed"); + let mut second = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut second); + assert_eq!(second.events, [egui::Event::PointerMoved(egui::pos2(2.0, 2.0))], "the current request still applies"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// Once a request has started, the focus/dialog change caused by its own first batch must not + /// drop its release: a key press without its release leaves `keys_down` stuck. + #[test] + fn a_started_request_finishes_its_batches_after_focus_moves() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 7; + s.inject.push_back(RequestBatches { + request: 9, + epoch: 7, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([ + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ]), + }); + } + let ctx = egui::Context::default(); + let mut press = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut press); + assert!(matches!(press.events.as_slice(), [egui::Event::Key { pressed: true, .. }])); + // The press moved focus: the next pass sees another input-target generation. + shared.lock().unwrap().epoch = 8; + let mut release = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut release); + assert!(matches!(release.events.as_slice(), [egui::Event::Key { pressed: false, .. }]), "the release still arrives"); + let s = shared.lock().unwrap(); + assert!(s.dropped.is_empty(), "a started request is not reported as stale"); + assert!(s.inject.is_empty()); + } + + /// A host for timeout tests: the control layer must not need a real app. + struct NoopHost; + + impl Host for NoopHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + + /// A host that starts unready (fonts not installed) and counts dispatched state reads. + /// `state` takes `&self`, so the count sits in a `Cell`. + struct ReadinessHost { + ready: bool, + states: std::cell::Cell, + /// Methods the host was asked to run, in dispatch order. + calls: std::cell::RefCell>, + } + + impl ReadinessHost { + fn new(ready: bool) -> Self { + Self { ready, states: std::cell::Cell::new(0), calls: std::cell::RefCell::new(Vec::new()) } + } + } + + impl Host for ReadinessHost { + fn state(&self) -> Value { + self.states.set(self.states.get() + 1); + self.calls.borrow_mut().push("ui.state"); + json!({ "ready": self.ready }) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + self.calls.borrow_mut().push("ui.commands"); + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + fn accepts_input(&self) -> bool { + self.ready + } + } + + /// The admission timestamp of the one pending request, whichever kind it is. + fn pending_since(control: &Control) -> Option { + let first = control.pending.first()?; + match first { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => Some(*since), + } + } + + #[test] + fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() { + for ready in [false, true] { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(ready); + let (reply, waiting) = channel(); + let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap(); + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch"); + assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}"); + assert_eq!(host.states.get(), 0); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + } + + /// A request held during startup keeps its arrival time. Once the app accepts input, one + /// older than the control deadline fails instead of being dispatched into a later UI. + #[test] + fn a_deferred_request_expires_instead_of_dispatching_after_the_deadline() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "unready host defers the request"); + assert!(waiting.try_recv().is_err(), "it is not answered while starting"); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + // It expires even while the app is still not ready, so the client is not left waiting. + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired deferred request receives a reply").expect_err("it must not dispatch"); + assert!(error.contains("still starting"), "{error}"); + assert!(control.deferred.is_empty()); + // And readiness arriving later cannot revive it. + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + } + + /// A request deferred only briefly still runs once fonts are ready. + #[test] + fn a_fresh_deferred_request_still_dispatches_when_the_app_becomes_ready() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert!(waiting.try_recv().is_err()); + host.ready = true; + control.tick(&ctx, &mut host); + let reply = waiting.try_recv().expect("fresh deferred request replies").expect("it dispatches"); + assert_eq!(reply["ready"], true); + assert_eq!(host.states.get(), 1, "the fresh deferred request is dispatched once"); + } + + /// A request deferred during startup keeps its one arrival timestamp. Dispatching it into + /// synthetic input, a deferred frame reply or a screenshot reservation must not start a fresh + /// 5 s in that phase: every later phase measures the same deadline from arrival. + #[test] + fn a_startup_wait_counts_against_one_admission_deadline_in_every_phase() { + let params = [("ui.type", json!({ "text": "x" })), ("ui.resize", json!({ "width": 800, "height": 600 })), ("ui.screenshot", json!({}))]; + for (method, params) in params { + // `ui.resize` needs a native window; the other phases are checked on every target. + if method == "ui.resize" && cfg!(target_arch = "wasm32") { + continue; + } + let ctx = egui::Context::default(); + // A real window size, so an admitted screenshot has capturable geometry. + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send(method, params); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "{method}: deferred while starting"); + // Three of the five seconds were spent starting: the request is still inside its + // deadline, but only just. + let arrived = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(3)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = arrived; + } + host.ready = true; + control.tick(&ctx, &mut host); + assert!(control.deferred.is_empty(), "{method}: dispatched once the app accepts input"); + assert!(waiting.try_recv().is_err(), "{method}: a preserved deadline does not answer early"); + assert_eq!(pending_since(&control), Some(arrived), "{method}: the pending reply keeps the arrival deadline"); + if method == "ui.type" { + let shared = control.shared.lock().unwrap(); + assert_eq!(shared.inject.front().map(|request| request.since), Some(arrived), "{method}: queued input keeps the arrival deadline"); + } + // Move that same shared timestamp past its own deadline (five seconds after arrival). + // Expiry measures the one admission instant, so nothing waits for a fresh five. + let past = arrived.checked_sub(timeout()).expect("a representable instant"); + for pending in &mut control.pending { + match pending { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => *since = past, + } + } + if let Ok(mut shared) = control.shared.lock() { + for request in &mut shared.inject { + request.since = past; + } + } + control.finish_pending(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it must not succeed"); + assert!(error.contains("not being drawn"), "{method}: {error}"); + assert!(control.pending.is_empty(), "{method}: the expired request is not retried"); + assert_eq!(control.reserved_bytes(), 0, "{method}: any reservation is released"); + } + } + + /// One client's requests keep their arrival order: a request deferred during startup runs + /// before a newer request that was still waiting in the channel when the app became ready. + #[test] + fn a_deferred_request_runs_before_a_newer_queued_one() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let older = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "the older request is deferred while starting"); + let newer = client.send("ui.commands", json!({})); + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(*host.calls.borrow(), ["ui.state", "ui.commands"], "the older deferred request is dispatched first"); + assert_eq!(older.try_recv().expect("the older request replies").expect("it dispatches")["ready"], true); + assert_eq!(newer.try_recv().expect("the newer request replies").expect("it dispatches"), json!([])); + } + + /// A request whose startup wait used up the deadline is refused once the app is ready, and the + /// message says the app finished starting instead of claiming it is still starting. + #[test] + fn a_request_that_outlasted_startup_reports_that_the_app_finished_starting() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + host.ready = true; + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it is never dispatched"); + assert!(error.contains("finished starting"), "{error}"); + assert!(!error.contains("was still starting"), "the app is ready now: {error}"); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + + /// Keep key/modifier cleanup, but a canceled pointer must never produce a release event. + #[test] + fn a_timed_out_started_request_cancels_pointer_and_releases_keys() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let pos = egui::pos2(24.0, 32.0); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + let ids = control + .inject( + vec![ + vec![ + egui::Event::ModifiersChanged(held), + key(true), + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: held }, + ], + vec![egui::Event::PointerMoved(egui::pos2(40.0, 48.0))], + vec![key(false), egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: held }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Enter) && i.pointer.primary_down())); + assert_eq!(ctx.input(|i| i.modifiers), held); + age_input(&mut control); + let next = egui::pos2(80.0, 90.0); + control.inject(vec![vec![egui::Event::PointerMoved(next)]], web_time::Instant::now()).unwrap(); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!ui.input(|i| i.keys_down.contains(&egui::Key::Enter))); + assert_eq!(ui.input(|i| i.modifiers), egui::Modifiers::NONE); + assert_eq!(ui.input(|i| i.pointer.hover_pos()), Some(next), "another live request survives"); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().dragged_id().is_none() && ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(control.shared.lock().unwrap().inject.is_empty()); + assert!(rx.try_recv().is_err(), "only one timeout reply"); + } + + #[test] + fn a_deadline_crossing_inside_an_admitted_pass_cancels_before_the_next_interaction() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let pos = egui::pos2(24.0, 32.0); + let ids = control + .inject( + vec![ + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(ui.input(|i| i.pointer.primary_down()), "the press was admitted before expiry"); + age_input(&mut control); + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(rx.try_recv().is_err(), "do not cancel an already-calculated interaction mid-pass"); + }) + .drop_without_applying_deltas(); + ctx.run_ui(egui::RawInput::default(), |ui| { + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.shared.lock().unwrap().inject.is_empty()); + } + + /// Deterministic elapsed-time input: age the same admission in both halves, without sleeping + /// or fabricating any egui pointer/widget state. + fn age_input(control: &mut Control) { + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for request in &mut control.shared.lock().unwrap().inject { + request.since = past; + } + for pending in &mut control.pending { + if let Pending::Frames { since, .. } = pending { + *since = past; + } + } + } + + /// The public gestures this test drives through real UI passes. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Gesture { + AtomicClick, + HeldClick, + Marquee, + } + + fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) { + match gesture { + Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})), + Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})), + Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})), + } + } + + fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) { + let client_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = client_slot.clone(); + let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap(); + app.set_option("quick", "marquee-zoom").unwrap(); + *slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + let client = client_slot.lock().unwrap().take().unwrap(); + (h, client) + } + + fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + } + + /// The actual laid-out page rect and a short point gesture inside it. + fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) { + let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect()); + assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}"); + let from = page.center(); + let to = from + egui::vec2(40.0, 50.0); + assert!(page.contains(to)); + (from, to) + } + + /// Step until the public request has really reached egui and can still be interrupted. + fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool { + let mut admitted = false; + for _ in 0..8 { + marquee_step(h); + admitted = if gesture == Gesture::AtomicClick { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1) + } else { + h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some()) + }; + if admitted { + break; + } + } + admitted + } + + /// The request is queued exactly once, with the expected terminal batch not yet applied. + fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) { + { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + assert_eq!(shared.inject.len(), 1, "only the actual public request is queued"); + let pending = shared.inject.front().unwrap(); + assert!(pending.started); + let terminal = pending.batches.back().expect("the terminal batch is still pending"); + if gesture == Gesture::AtomicClick { + assert_eq!(pending.batches.len(), 1); + assert_eq!(terminal.len(), 2); + assert!( + matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!( + matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far"); + } else { + assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press"); + assert_eq!(terminal.len(), 1); + assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. })); + } + } + assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run"); + assert!(h.ctx.drag_stopped_id().is_none()); + if gesture == Gesture::Marquee { + assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview"); + } else { + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + } + } + + /// Expire the admission both through hidden logic and through the next input hook, then + /// assert that cancellation left no gesture, zoom or edit behind. + fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver, hidden_logic: bool, zoom: f32, bytes: &[u8]) { + age_input(h.state_mut().control.as_mut().unwrap()); + if hidden_logic { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let mut control = app.control.take().unwrap(); + control.tick(&ctx, app); + app.control = Some(control); + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + marquee_step(h); + if !hidden_logic { + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + for _ in 0..2 { + marquee_step(h); + } + assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none()); + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit"); + assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty()); + assert!(h.state().control.as_ref().unwrap().pending.is_empty()); + assert!(reply.try_recv().is_err(), "the cancellation replies once"); + } + + /// Complete one public gesture and check its real event trace and one-step zoom. + fn complete_positive_gesture( + h: &mut Harness<'static, crate::PdfCraftApp>, + client: &ControlClient, + gesture: Gesture, + positive: Gesture, + bytes: &[u8], + ) { + let (from, to) = gesture_target(h); + let zoom = h.state().views[0].zoom; + let (method, params) = gesture_request(positive, from, to); + let reply = client.send(method, params); + let mut value = None; + let mut buttons = Vec::new(); + let mut positions = Vec::new(); + for _ in 0..20 { + marquee_step(h); + h.ctx.input(|i| { + for event in &i.events { + match event { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => { + buttons.push((*pressed, i.time)); + positions.push(*pos); + } + egui::Event::PointerMoved(pos) => positions.push(*pos), + _ => {} + } + } + }); + if let Ok(result) = reply.try_recv() { + value = Some(result.unwrap()); + break; + } + } + assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}"); + assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::>(), [true, false]); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + assert!(h.ctx.dragged_id().is_none()); + if positive == Gesture::Marquee { + assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms"); + } else { + let options = h.ctx.options(|o| o.input_options); + assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click"); + assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary"); + assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step"); + } + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document"); + assert!(reply.try_recv().is_err(), "successful request replies once"); + } + + /// Real page hit-testing, real control batches and the production marquee completion path. + /// Exercise both hidden-logic expiry and expiry first observed by the next input hook. + #[test] + fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() { + for hidden_logic in [false, true] { + for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] { + let (mut h, client) = marquee_harness(); + for _ in 0..5 { + marquee_step(&mut h); + } + let (from, to) = gesture_target(&h); + let zoom = h.state().views[0].zoom; + let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone(); + let (method, params) = gesture_request(gesture, from, to); + let reply = client.send(method, params); + assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}"); + assert_interruptible_admission(&h, gesture, from); + assert!(reply.try_recv().is_err(), "the terminal batch has not run"); + assert_eq!(h.state().views[0].zoom, zoom); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes); + assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes); + + // Complete the same public gesture, then an atomic click after held input. + // Real event traces prove the stationary drag is a valid short point click. + let positives = [gesture, Gesture::AtomicClick]; + let count = if gesture == Gesture::AtomicClick { 1 } else { 2 }; + for positive in positives.into_iter().take(count) { + complete_positive_gesture(&mut h, &client, gesture, positive, &bytes); + } + } + } + } + + /// A screenshot that times out cancels its slot and gives its reservation back, with a + /// defined error instead of a silent success. + #[test] + fn a_timed_out_screenshot_releases_its_slot_and_budget() { + struct FakeHost; + impl Host for FakeHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap_or_else(web_time::Instant::now), + geometry: Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, epoch: 0 }, + reserved: 800 * 600 * 4, + attempts: 0, + }); + control.finish_pending(&ctx, &mut FakeHost); + let error = rx.try_recv().expect("the request is answered").expect_err("a timeout is an error"); + assert!(error.contains("not being drawn"), "{error}"); + assert_eq!(control.reserved_bytes(), 0, "the reservation is released"); + assert!(control.pending.is_empty(), "the cancelled capture is not retried"); + } + + /// A real successful callback must not bypass expiry, including when resize would otherwise + /// retry it. A non-expired completion still produces a real encoded PNG reply. + #[test] + fn completed_screenshots_obey_the_admission_deadline_before_success_or_retry() { + for (expired, resized) in [(false, false), (true, false), (true, true)] { + let ctx = egui::Context::default(); + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 32.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, _client) = attach(&ctx); + let geometry = Geometry::current(&ctx, 0); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + callback.complete(image); + assert!(weak_image.upgrade().is_some(), "the completed slot really retains pixels"); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: None, + region: None, + reply, + since: if expired { + web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap() + } else { + web_time::Instant::now() + }, + geometry, + reserved: geometry.image_bytes().unwrap(), + attempts: 0, + }); + if resized { + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(80.0, 40.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + assert_ne!(Geometry::current(&ctx, 0).size, geometry.size); + } + control.finish_pending(&ctx, &mut NoopHost); + let result = rx.try_recv().expect("one settled reply"); + if expired { + assert!(result.unwrap_err().contains("not being drawn"), "expiry takes priority over success or resize"); + } else { + let image = result.expect("the live completed capture still succeeds"); + assert_eq!(image["width"], 64); + assert_eq!(image["height"], 32); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(image["png_base64"].as_str().expect("actual PNG reply")).unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } + assert!(control.pending.is_empty(), "an expired capture is never retried"); + assert_eq!(control.reserved_bytes(), 0); + assert!(weak_image.upgrade().is_none(), "the slot releases its pixels"); + assert!(rx.try_recv().is_err(), "exactly one reply"); + } + } + + #[test] + fn expired_unissued_screenshots_are_not_issued_and_late_callbacks_are_harmless() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let late_callback = callback.clone(); + let geometry = Geometry { size: egui::vec2(64.0, 32.0), pixels_per_point: 1.0, epoch: 0 }; + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(), + geometry, + reserved: 64 * 32 * 4, + attempts: 0, + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| control.finish_frame(ui.ctx(), &mut NoopHost)); + assert!( + output + .viewport_output + .values() + .all(|viewport| !viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::Screenshot(_)))) + ); + output.drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + assert_eq!(control.reserved_bytes(), 0); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + late_callback.complete(image); + assert!(weak_image.upgrade().is_none(), "a late completion cannot resurrect a canceled request"); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().is_err(), "no second reply"); + } + + /// One queued batch is consumed by exactly one UI pass. + #[test] + fn one_batch_is_consumed_by_one_pass() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 3; + s.inject.push_back(RequestBatches { + request: 1, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 3.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 2, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(4.0, 5.0))]]), + }); + } + let mut first = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut first); + assert_eq!(first.events.len(), 1); + let mut second = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut second); + assert_eq!(second.events.len(), 1); + assert_eq!(second.events[0], egui::Event::PointerMoved(egui::pos2(4.0, 5.0))); + let mut third = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut third); + assert!(third.events.is_empty(), "a consumed batch is never repeated"); + } +} + +#[cfg(test)] +mod screenshot_geometry_tests { + use super::*; + + #[test] + fn image_bytes_are_checked_against_overflow_and_limits() { + assert_eq!(checked_image_bytes(2, 3), Some(24)); + assert_eq!(checked_image_bytes(usize::MAX, 2), None); + let big = Geometry { size: egui::vec2(10_000.0, 10_000.0), pixels_per_point: 8.0, epoch: 0 }; + assert!(big.image_bytes().is_some_and(|bytes| bytes > MAX_IMAGE_BYTES)); + } + + /// The crop is derived from the returned pixels and the admitted logical rect, not from a + /// current `pixels_per_point`: a fitted surface may return a smaller image. + #[test] + fn region_crop_uses_the_capture_geometry() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 50.0), pixels_per_point: 2.0, epoch: 7 }; + let reply = + screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(10.0, 5.0), egui::pos2(60.0, 30.0))), geometry).expect("a valid crop"); + assert_eq!(reply["width"], 100); + assert_eq!(reply["height"], 50); + assert_eq!(reply["pixels_per_point"], 2.0); + } + + /// A retry replaces one reservation in a running aggregate. Three 50 MiB captures that each + /// retry to 100 MiB must not all be admitted against a stale 150 MiB snapshot. + #[test] + fn retries_keep_one_running_aggregate_budget() { + const MIB: usize = 1024 * 1024; + let first = retried_reservation(3 * 50 * MIB, 50 * MIB, 100 * MIB).expect("the first retry fits"); + let second = retried_reservation(first, 50 * MIB, 100 * MIB).expect("the second retry fits"); + assert_eq!(second, 250 * MIB); + assert_eq!(retried_reservation(second, 50 * MIB, 100 * MIB), None, "the third would pass 256 MiB"); + assert_eq!(retried_reservation(0, 0, MAX_IMAGE_BYTES + 1), None, "a single image over the per-image limit never fits"); + } + + #[test] + fn an_image_with_another_aspect_ratio_is_rejected() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 60.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, None, geometry).is_err()); + } + + #[test] + fn an_outside_region_is_rejected_not_panicking() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(11.0, 6.0), egui::pos2(15.0, 9.0))), geometry).is_err()); + } + + /// A region that a shrink now cuts off is refused, not silently clamped to a smaller crop. + #[test] + fn a_region_cut_by_a_shrink_is_rejected_instead_of_cropped() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let shrunk = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + let region = egui::Rect::from_min_max(egui::pos2(8.0, 4.0), egui::pos2(15.0, 9.0)); + assert!(!shrunk.contains_region(region)); + let error = screenshot_png(&image, Some(region), shrunk).expect_err("a cut region must not become a smaller image"); + assert!(error.contains("inside the window"), "{error}"); + } + + /// Screenshot identity is the captured content, not the window's monitor-space origin or the + /// focused widget: only the capture epoch and the viewport-local size/scale matter. + #[test] + fn capture_geometry_compares_size_scale_and_capture_epoch_only() { + let admitted = Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }; + assert_eq!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(801.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.5, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 6 }); + assert!(admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(800.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(801.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(-1.0, 0.0), egui::pos2(10.0, 10.0)))); + } +} + #[cfg(test)] mod pointer_tests { use super::*; @@ -1436,6 +3182,8 @@ mod pointer_tests { let slow_dt = (max_duration + 0.25) as f32; assert!(f64::from(slow_dt) > max_duration); for discard in [false, true] { + // This probe draws its own UI with the harness's fonts, not PdfCraftApp::ui. + // Exercise the readiness gate explicitly before allowing this host's input. let state = Probe { control: None, client: None, @@ -1484,6 +3232,26 @@ mod pointer_tests { ); h.run_steps(3); let c = h.state().client.as_ref().unwrap().clone(); + let probe_pos = h.get_by_label("Click probe").rect().center(); + let waiting = c.send("ui.click", json!({"x":probe_pos.x,"y":probe_pos.y})); + h.run_steps(3); + assert!(waiting.try_recv().is_err(), "unready host must defer control input"); + assert_eq!(h.state().primary, 0); + assert!(h.state().events.is_empty()); + h.state_mut().host.fonts_ready = true; + let mut ready_reply = None; + for _ in 0..8 { + h.step(); + if let Ok(reply) = waiting.try_recv() { + ready_reply = Some(reply); + break; + } + } + assert!(ready_reply.expect("ready host completes deferred click").is_ok()); + assert_eq!(h.state().primary, 1); + h.state_mut().primary = 0; + h.state_mut().events.clear(); + h.state_mut().discarded = false; assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration); let pos = h.get_by_label("Click probe").rect().center(); assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (0, 0, 0)); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 59535f35e6..4fe58a6714 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -232,7 +232,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Show")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6f2477572..b3437246a0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { unlock(app, ctx); } +/// Draw the active modal dialog and apply the action the user chose. fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); @@ -154,6 +155,22 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { if tab == PropsTab::SpaceAudit { ui.add_space(4.0); @@ -162,7 +179,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { PropsTab::Description => { - row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); + path_row(ui, "File", doc.name.clone()); match app.props_draft.as_mut() { Some((_, draft)) if editable_info => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { @@ -192,7 +209,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((_, v)) = app.view_draft.as_mut() else { return }; ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); ui.end_row(); - ui.label(tl!("Navigation tab")); + let label = ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { egui::ComboBox::from_id_salt("iv-nav") .selected_text(tl!(format!("{:?}", v.navigation) @@ -209,10 +226,12 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ] { ui.selectable_value(&mut v.navigation, n, l); } - }); + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Page layout")); + let label = ui.label(tl!("Page layout")); ui.add_enabled_ui(editable, |ui| { let names = [ (L::Default, tl!("Default")), @@ -224,14 +243,18 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }); + egui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Magnification")); + let label = ui.label(tl!("Magnification")); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { let names = [ @@ -246,21 +269,25 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { M::Percent(p) => format!("{p:.0}%"), m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; - egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }); + egui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }) + .response + .labelled_by(label.id); }); }); ui.end_row(); - ui.label(tl!("Open to page")); + let label = ui.label(tl!("Open to page")); ui.add_enabled_ui(editable, |ui| { let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() { + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { v.page = p - 1; } ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); @@ -455,7 +482,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } PropsTab::Advanced => { row(ui, "PDF version", i.pdf_version.clone()); - row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); let p = &i.pages[0]; row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); @@ -606,10 +633,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { d.to = d.to.clamp(1, count); d.from = d.from.clamp(1, d.to); ui.horizontal(|ui| { - ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); - ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); if from.changed() || to.changed() { d.range_edited = true; } @@ -619,8 +646,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let max_start = d.src_pages.saturating_sub(n) + 1; d.src_from = d.src_from.clamp(1, max_start.max(1)); ui.horizontal(|ui| { - ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))); + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); }); @@ -1286,7 +1313,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::About => { // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal_wrapped(|ui| { for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { @@ -1882,7 +1909,16 @@ fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.label(tl!("This document is trying to open a local file:")); egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { - ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true)); + // A local path is file/path chrome: left to right, so its final component stays last. + let mut job = egui::text::LayoutJob::simple( + pending.path.to_string_lossy().into_owned(), + egui::FontId::monospace(13.0), + crate::theme::Tokens::get(ui.ctx()).text, + ui.available_width().max(0.0), + ); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(true)); }); ui.add_space(8.0); ui.label(tl!("Only allow this if you trust the source document.")); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f67398d84f..4f5630c0b9 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -471,6 +471,19 @@ pub struct ImageSelection { settled: Option<(Rect, Rect)>, } +impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.drag = None; + self.turning = false; + if let Some(crop) = &mut self.crop + && let Some((_, _, before)) = crop.drag.take() + { + crop.keep = before; + } + } +} + /// Cropping a page image: the handles trim instead of resizing, the part that will go is /// dimmed, Enter or Done applies and Esc or Cancel leaves it. #[derive(Clone, Debug, PartialEq)] @@ -1615,7 +1628,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new("edit-text-line")) + egui::Area::new(egui::Id::unique("edit-text-line")) .order(egui::Order::Foreground) .fade_in(false) .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) @@ -1677,7 +1690,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; let output = egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) + .id(egui::Id::unique("edit-text-line-input")) .font(font.clone()) .margin(egui::Margin::ZERO) .layouter(&mut layouter) @@ -1686,6 +1699,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .desired_width(width) .desired_rows(rows) .show(ui); + // The editor paints over the page with no visible label; name it for assistive tech. + output.response.response.clone().accessible_name(tl!("Text editing line")); if ed.has_changes() { if background_ready { reveal = true; diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs index d9b5255bfd..6dacf67a65 100644 --- a/crates/ui-egui/src/field_table_ui.rs +++ b/crates/ui-egui/src/field_table_ui.rs @@ -159,7 +159,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let mut choice = None; let mut laid = None; egui::Window::new(tl!("Text Field Table")) - .id(egui::Id::new("field_table_window")) + .id(egui::Id::unique("field_table_window")) .open(&mut open) .collapsible(false) .resizable(false) @@ -255,15 +255,20 @@ fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[For for (c, column) in d.table.columns.iter_mut().enumerate() { let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]); let share = shares.get(c).copied().unwrap_or_default(); + let mut column_label_response = None; ui.horizontal(|ui| { - ui.label(RichText::new(column_label(c)).strong()); + let label = ui.label(RichText::new(column_label(c)).strong()); + column_label_response = Some(label.id); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0)); ui.label(RichText::new(size).color(t.text_muted).size(11.0)); }); }); let full = ui.available_width(); - ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + let pattern = ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + if let Some(label) = column_label_response { + pattern.labelled_by(label); + } ui.add_space(4.0); } }; @@ -375,7 +380,7 @@ fn column_dividers(ui: &mut egui::Ui, d: &mut FieldTableDraft, bar: Rect, ends: let mut active = None; for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) { let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom())); - let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag()); + let resp = ui.interact(hit, ui.scope_id().with(("field table divider", i)), Sense::click_and_drag()); let share = d.shares().get(i).copied().unwrap_or_default(); resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i))); let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width()); @@ -469,7 +474,7 @@ mod tests { |ui| { let other = ui.button("Other control"); if let Some(i) = focus { - ui.memory_mut(|memory| memory.request_focus(ui.id().with(("field table divider", i)))); + ui.memory_mut(|memory| memory.request_focus(ui.scope_id().with(("field table divider", i)))); } else { other.request_focus(); } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 862b63e72d..aab071b508 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -555,7 +555,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( @@ -718,7 +718,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // Over the page, so only where the document shows: a box scrolled under the mode bar or a // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("fill-text", view.id.0))) + egui::Area::new(egui::Id::unique(("fill-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(pos) @@ -732,15 +732,17 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); - let r = ui.add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) - .desired_width(width) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) - .text_color(Color32::BLACK) - .hint_text(tl!("Type text")) - .id_salt("fill-text-edit"), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text(tl!("Type text")) + .id_salt("fill-text-edit"), + ) + .accessible_name(tl!("Text to place")); if t.focus { r.request_focus(); t.focus = false; @@ -911,10 +913,10 @@ fn scan_tab( } let Some(scan) = d.scan.as_mut() else { painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); return browse; }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); // The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points. let [pw, ph] = scan.photo.size(); let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32); diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs index cce004ce53..b5eafe69d1 100644 --- a/crates/ui-egui/src/fling.rs +++ b/crates/ui-egui/src/fling.rs @@ -64,7 +64,7 @@ impl Fling { /// (animations off, single-page paging, a zoom, a dialog). pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) { for event in events { - let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + let Event::MouseWheel { unit, delta, phase, modifiers, .. } = event else { continue }; self.stop(); // Zooming, a mouse wheel and sideways Shift scrolling never glide. if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift { @@ -160,7 +160,13 @@ mod tests { use egui::{Modifiers, vec2}; fn wheel(dy: f32, phase: TouchPhase) -> Event { - Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } /// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last. @@ -213,6 +219,7 @@ mod tests { assert!(!fling.gliding(), "the finger rested before it lifted"); } + /// Check that new finger or wheel input stops an in-progress glide. #[test] fn new_input_stops_a_glide() { let mut fling = Fling::new(true); @@ -222,11 +229,18 @@ mod tests { let mut fling = Fling::new(true); let now = swipe(&mut fling, -25.0, 6); - let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + let notch = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; fling.read(&[notch], now + 0.1, true); assert!(!fling.gliding(), "a wheel notch"); } + /// Check that disabled flinging, a false glide flag, and a command-modified zoom gesture prevent glide. #[test] fn no_glide_where_it_is_off_or_not_wanted() { let mut off = Fling::new(false); @@ -242,7 +256,13 @@ mod tests { assert!(!fling.gliding(), "animations off, paging or a dialog"); let mut zoom = Fling::new(true); - let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND }; + let ctrl = |phase| Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, -25.0), + phase, + modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true); zoom.read(&[ctrl(TouchPhase::Move)], dt, true); zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true); diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs index 727417e2fc..fe9b2c3706 100644 --- a/crates/ui-egui/src/folders_ui.rs +++ b/crates/ui-egui/src/folders_ui.rs @@ -314,20 +314,21 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); - ui.label(egui::RichText::new(crate::bidi::visual(&name)).font(theme::semibold(14.0))).on_hover_text(folder); + // Folder names and paths are file/path chrome: explicitly left to right, so an + // Arabic location keeps its `.pdf`/extension at the visual end. + widgets::label_ltr(ui, name.clone(), theme::semibold(14.0), t.text).on_hover_text(folder); ui.add_space(4.0); // Leave room for Unpin: a long location is cut short, never the button. let room = (ui.available_width() - 110.0).max(0.0); ui.scope(|ui| { ui.set_max_width(room); - let place = egui::RichText::new(crate::bidi::visual(&location(folder))).font(theme::regular(12.0)).color(t.text_faint); - ui.add(egui::Label::new(place).truncate()).on_hover_text(folder); + widgets::label_ltr(ui, location(folder), theme::regular(12.0), t.text_faint).on_hover_text(folder); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } @@ -353,7 +354,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let shown = if expanded { listing.files.len() } else { listing.files.len().min(SHOWN) }; for f in listing.files.iter().take(shown) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &f.name)); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); } @@ -369,7 +370,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let detail = if when.is_empty() { size } else { format!("{when} · {size}") }; let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); let width = detail.left() - 16.0 - (rect.left() + 62.0); - widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width); + widgets::row_text(ui, rect.min + vec2(62.0, 19.0), f.name.as_str(), theme::medium(13.0), t.text, width); if resp.on_hover_text(&f.path).clicked() { open = Some(f.path.clone()); } diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 9849920ac6..69a49704a2 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -280,7 +280,7 @@ fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { /// labels. fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), label)); let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); resp.on_hover_text(label) @@ -317,7 +317,7 @@ fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: /// The text colour as a square swatch that opens the palette. fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); let fill = rect.shrink(4.0); ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8caa44ced2..28494160c5 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -322,7 +322,7 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { #[allow(clippy::too_many_arguments)] fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) { let mut enter = false; - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -340,8 +340,9 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, widget))); - let r = ui.add_sized(rect.size(), te); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + // The form field's own logical name; not a visual/preordered label. + let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -381,7 +382,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st fx.calendar_text = Some(fx.text.clone()); } let mut picked = None; - let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0))) + let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(field.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -672,7 +673,7 @@ pub(crate) fn overlay( // Over the page, so only where the document shows: a field scrolled under the mode bar // or a side panel must leave them their pointer (#2755). It stays on its field, cut off // as the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -697,7 +698,7 @@ pub(crate) fn overlay( .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } @@ -705,11 +706,14 @@ pub(crate) fn overlay( te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); } let mut laid_out = None; - let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }); + let r = ui + .add_sized(rect.size(), |ui: &mut egui::Ui| { + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }) + // The form field's own logical name; not a visual/preordered label. + .accessible_name(f.name.clone()); if let (Some(tb), Some(before)) = (text_box, before) && fx.text != before { @@ -784,7 +788,7 @@ pub(crate) fn overlay( if editable_combo(f) { combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget); } - let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) + let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -843,10 +847,10 @@ pub(crate) fn overlay( FormFieldKind::CheckBox | FormFieldKind::Radio => { // Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so // Space, Tab and the arrows come here instead of turning or scrolling the page. - let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)); + let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)); let mut has_focus = false; // Not interactable, so clicks still reach the page under it. - let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); + let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); area.fixed_pos(rect.min).show(ctx, |ui| { let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); if let Some(fx) = view.forms.focus.as_mut() diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 59b0a52bb3..bfabf701e6 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -18,6 +18,7 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre } } +/// Draw the Home page with the welcome text, community card and recent documents. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -72,7 +73,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { for id in RECOMMENDED { let Some(g) = catalog::group(id) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); let fill = press.fill(t.card, t.hover, t.pressed); @@ -110,7 +111,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file"))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); ui.painter().rect( @@ -142,7 +143,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if !app.recent.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); }); } @@ -155,7 +156,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut remove = None; for r in &app.recent { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); let press = theme::Press::track(ui, &resp); // The row, not its response: the pointer stays "in" the row over its remove button. let hovered = ui.rect_contains_pointer(rect); @@ -168,9 +169,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if hovered { // Remove just this file from the list (#430); the file itself is untouched. let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0)); - let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click()); + let x = ui.interact(x_rect, ui.scope_id().with(("remove-recent", &r.path)), Sense::click()); let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]); - x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if x.hovered() { ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed); } @@ -197,8 +198,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); // The name and path stop short of the page count; hovering shows the whole path. let width = detail.left() - 16.0 - (body.left() + 46.0); - widgets::row_text(ui, body.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width); - widgets::row_text(ui, body.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width); + widgets::row_text(ui, body.min + vec2(46.0, 15.0), r.name.as_str(), theme::medium(13.5), t.text, width); + widgets::row_text(ui, body.min + vec2(46.0, 32.0), r.path.as_str(), theme::regular(11.0), t.text_faint, width); if theme::hand(resp).on_hover_text(&r.path).clicked() { open = Some(r.path.clone()); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8b951d6f6d..3e6d735523 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu القائمة File ملف @@ -184,6 +184,15 @@ Available متاح Ready جاهز Search tools and commands… البحث في الأدوات والأوامر… + +# Accessible names for inputs that have no visible label. + Added text النص المضاف + Link URL عنوان URL للرابط + Search tools and commands البحث في الأدوات والأوامر + Text editing line سطر تحرير النص + Text to place النص المراد وضعه + Tooltip تلميح + Words or phrases to redact الكلمات أو العبارات المراد حجبها No matching tools لا توجد أدوات مطابقة Tool أداة Command أمر diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c3a692c495..ec0339f18a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1832,6 +1832,15 @@ Tool Инструмент Command Команда Search tools and commands… Търсене на инструменти и команди… + +# Accessible names for inputs that have no visible label. + Added text Добавен текст + Link URL URL на връзката + Search tools and commands Търсене на инструменти и команди + Text editing line Ред за редактиране на текст + Text to place Текст за поставяне + Tooltip Подсказка + Words or phrases to redact Думи или фрази за заличаване No matching tools Няма съответстващи инструменти All tools Всички инструменти Read Четене diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cc888dc973..0cacae3c2c 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -178,6 +178,15 @@ Available Disponible Ready Llest Search tools and commands… Cerca eines i ordres… + +# Accessible names for inputs that have no visible label. + Added text Text afegit + Link URL URL de l'enllaç + Search tools and commands Cerca eines i ordres + Text editing line Línia d'edició de text + Text to place Text que cal col·locar + Tooltip Suggeriment + Words or phrases to redact Paraules o frases per censurar No matching tools Cap eina coincident Tool Eina Command Ordre diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 864cdb434f..7bf4296662 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -155,15 +155,6 @@ impl Catalog { (c, errors) } - /// Rewrite every translation (each plural form on its own), e.g. into display order for a - /// right-to-left language. Keys are untouched. - pub fn map_translations(&mut self, f: impl Fn(&str) -> String) { - let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.ids)); - for translation in tables.flat_map(HashMap::values_mut).chain(self.plurals.values_mut().flatten()) { - *translation = f(translation); - } - } - pub fn plain(&self, s: &str) -> Option<&str> { self.plain.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index db8fc1dab2..71c0c62869 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu پێڕست File فایل @@ -184,6 +184,15 @@ Available بەردەست Ready ئامادە Search tools and commands… گەڕان لە ئامرازەکان و فەرمانەکان… + +# Accessible names for inputs that have no visible label. + Added text دەقی زیادکراو + Link URL ناونیشانی URLی بەستەر + Search tools and commands گەڕان لە ئامرازەکان و فەرمانەکان + Text editing line هێڵی دەستکاریکردنی دەق + Text to place دەقی دانان + Tooltip ڕێنمایی + Words or phrases to redact وشە یان دەستەواژەکان بۆ شاردنەوە No matching tools هیچ ئامرازێکی گونجاو نییە Tool ئامراز Command فەرمان diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 28fb4e1d65..ed6d051b57 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -305,6 +305,15 @@ key Space Mezerník Available Dostupné Ready Připraveno Search tools and commands… Hledat nástroje a příkazy… + +# Accessible names for inputs that have no visible label. + Added text Přidaný text + Link URL Adresa URL odkazu + Search tools and commands Hledat nástroje a příkazy + Text editing line Řádek pro úpravu textu + Text to place Text k umístění + Tooltip Popisek + Words or phrases to redact Slova nebo fráze k začernění No matching tools Žádné odpovídající nástroje Tool Nástroj Command Příkaz diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bc202ef1ab..8586a21d43 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -157,6 +157,15 @@ Available Verfügbar Ready Bereit Search tools and commands… Tools und Befehle suchen… + +# Accessible names for inputs that have no visible label. + Added text Hinzugefügter Text + Link URL URL des Links + Search tools and commands Tools und Befehle suchen + Text editing line Textbearbeitungszeile + Text to place Einzufügender Text + Tooltip Tooltip + Words or phrases to redact Zu schwärzende Wörter oder Ausdrücke No matching tools Keine passenden Werkzeuge Tool Werkzeug Command Befehl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ebe7969656..e3eac77caa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2167,6 +2167,15 @@ View more Περισσότερα Available Διαθέσιμο Search tools and commands… Αναζήτηση εργαλείων και εντολών… + +# Accessible names for inputs that have no visible label. + Added text Προστιθέμενο κείμενο + Link URL Διεύθυνση URL συνδέσμου + Search tools and commands Αναζήτηση εργαλείων και εντολών + Text editing line Γραμμή επεξεργασίας κειμένου + Text to place Κείμενο προς τοποθέτηση + Tooltip Συμβουλή εργαλείου + Words or phrases to redact Λέξεις ή φράσεις για απόκρυψη No matching tools Δεν βρέθηκαν εργαλεία Tool Εργαλείο Command Εντολή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c839eb330a..0d4e505a70 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -180,6 +180,15 @@ Available Disponible Ready Listo Search tools and commands… Buscar herramientas y comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto añadido + Link URL URL del enlace + Search tools and commands Buscar herramientas y comandos + Text editing line Línea de edición de texto + Text to place Texto que colocar + Tooltip Información sobre herramientas + Words or phrases to redact Palabras o frases que censurar No matching tools Ninguna herramienta coincide Tool Herramienta Command Comando diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 297d3084b9..5cfc217533 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -14,9 +14,9 @@ # (Acrobat's included) was consulted or copied; add entries the same way. # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters. # -# Right to left: like Arabic and Kurdish, `rtl: true` in i18n/mod.rs puts every entry into display -# order when the catalog loads ([`crate::bidi::display_rtl`]); entries are written here in logical -# (typing) order. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins +# Right to left: like Arabic and Kurdish, entries stay in logical (typing) order. +# `rtl: true` in i18n/mod.rs selects explicit right-to-left text direction; epaint reorders +# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins # compound words. Menu منو diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a876b8aabc..a542ee9b91 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -178,6 +178,15 @@ Available Käytettävissä Ready Valmis Search tools and commands… Hae työkaluja ja komentoja… + +# Accessible names for inputs that have no visible label. + Added text Lisätty teksti + Link URL Linkin URL-osoite + Search tools and commands Hae työkaluja ja komentoja + Text editing line Tekstin muokkausrivi + Text to place Sijoitettava teksti + Tooltip Työkaluvihje + Words or phrases to redact Peitettävät sanat tai lauseet No matching tools Ei vastaavia työkaluja Tool Työkalu Command Komento diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bb935754b3..61c8b5171d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -157,6 +157,15 @@ Available Disponible Ready Prêt Search tools and commands… Rechercher des outils et des commandes… + +# Accessible names for inputs that have no visible label. + Added text Texte ajouté + Link URL URL du lien + Search tools and commands Rechercher des outils et des commandes + Text editing line Ligne de modification du texte + Text to place Texte à placer + Tooltip Info-bulle + Words or phrases to redact Mots ou expressions à biffer No matching tools Aucun outil correspondant Tool Outil Command Commande diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8b1de38dc3..e9d627aca9 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -162,6 +162,15 @@ Available उपलब्ध Ready तैयार Search tools and commands… टूल और कमांड खोजें… + +# Accessible names for inputs that have no visible label. + Added text जोड़ा गया टेक्स्ट + Link URL लिंक का URL + Search tools and commands टूल और कमांड खोजें + Text editing line टेक्स्ट संपादन पंक्ति + Text to place रखने के लिए टेक्स्ट + Tooltip टूलटिप + Words or phrases to redact रेडैक्ट करने के लिए शब्द या वाक्यांश No matching tools कोई मेल खाता टूल नहीं Tool टूल Command कमांड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 71bf7d36c8..6b37b9169b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -155,6 +155,15 @@ Available Elérhető Ready Kész Search tools and commands… Eszközök és parancsok keresése… + +# Accessible names for inputs that have no visible label. + Added text Hozzáadott szöveg + Link URL Hivatkozás URL-je + Search tools and commands Eszközök és parancsok keresése + Text editing line Szövegszerkesztő sor + Text to place Elhelyezendő szöveg + Tooltip Elemleírás + Words or phrases to redact Kitakarni kívánt szavak vagy kifejezések No matching tools Nincs illeszkedő eszköz Tool Eszköz Command Parancs diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 409a04cf46..292b336e10 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1011,6 +1011,15 @@ Scroll tabs right Gulir tab ke kanan Search comments Cari komentar Search tools and commands… Cari alat dan perintah… + +# Accessible names for inputs that have no visible label. + Added text Teks yang ditambahkan + Link URL URL tautan + Search tools and commands Cari alat dan perintah + Text editing line Baris penyuntingan teks + Text to place Teks yang akan ditempatkan + Tooltip Tooltip + Words or phrases to redact Kata atau frasa untuk disensor Searchable Image (Exact) Gambar yang Dapat Dicari (Tepat) Searching… {s} of {p} pages Mencari… {s} dari {p} halaman Searching… {s}/{p} Mencari… {s}/{p} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 11a9d4869b..20729bae6a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -181,6 +181,15 @@ Available Disponibile Ready Pronto Search tools and commands… Cerca strumenti e comandi… + +# Accessible names for inputs that have no visible label. + Added text Testo aggiunto + Link URL URL del collegamento + Search tools and commands Cerca strumenti e comandi + Text editing line Riga di modifica del testo + Text to place Testo da posizionare + Tooltip Descrizione comando + Words or phrases to redact Parole o frasi da oscurare No matching tools Nessuno strumento corrispondente Tool Strumento Command Comando diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a8c287bcf..1ed4d43b6c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -184,6 +184,15 @@ Available 使用可能 Ready 使用可能 Search tools and commands… ツールとコマンドを検索… + +# Accessible names for inputs that have no visible label. + Added text 追加したテキスト + Link URL リンクの URL + Search tools and commands ツールとコマンドを検索 + Text editing line テキスト編集行 + Text to place 配置するテキスト + Tooltip ツールチップ + Words or phrases to redact 墨消しする単語または語句 No matching tools 該当するツールがありません Tool ツール Command コマンド diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index a9eada9743..9022c3591c 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -145,6 +145,15 @@ Available Қолжетімді Ready Дайын Search tools and commands… Құралдар мен командаларды іздеу… + +# Accessible names for inputs that have no visible label. + Added text Қосылған мәтін + Link URL Сілтеменің URL мекенжайы + Search tools and commands Құралдар мен командаларды іздеу + Text editing line Мәтінді өңдеу жолы + Text to place Орналастырылатын мәтін + Tooltip Қалтқы + Words or phrases to redact Жасырылатын сөздер немесе сөз тіркестері No matching tools Сәйкес құралдар жоқ Tool Құрал Command Команда diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 98b68e85c7..09b9cad9fa 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -162,6 +162,15 @@ Available 사용 가능 Ready 준비됨 Search tools and commands… 도구 및 명령 검색… + +# Accessible names for inputs that have no visible label. + Added text 추가한 텍스트 + Link URL 링크 URL + Search tools and commands 도구 및 명령 검색 + Text editing line 텍스트 편집 줄 + Text to place 배치할 텍스트 + Tooltip 도구 설명 + Words or phrases to redact 민감 정보를 제거할 단어 또는 구문 No matching tools 일치하는 도구가 없습니다 Tool 도구 Command 명령 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 331b24501b..964467d823 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -182,6 +182,15 @@ Available ພ້ອມໃຊ້ງານ Ready ພ້ອມແລ້ວ Search tools and commands… ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ… + +# Accessible names for inputs that have no visible label. + Added text ຂໍ້ຄວາມທີ່ເພີ່ມ + Link URL URL ຂອງລິ້ງຄ໌ + Search tools and commands ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ + Text editing line ແຖວແກ້ໄຂຂໍ້ຄວາມ + Text to place ຂໍ້ຄວາມທີ່ຈະວາງ + Tooltip ຄຳແນະນຳເຄື່ອງມື + Words or phrases to redact ຄຳ ຫຼື ວະລີທີ່ຈະປິດບັງ No matching tools ບໍ່ພົບເຄື່ອງມືທີ່ກົງກັນ Tool ເຄື່ອງມື Command ຄຳສັ່ງ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2c151b2554..43749f4ec8 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1067,6 +1067,15 @@ Scroll tabs right Slinkti korteles dešinėn Search comments Ieškoti komentaruose Search tools and commands… Ieškoti įrankių ir komandų… + +# Accessible names for inputs that have no visible label. + Added text Pridėtas tekstas + Link URL Saito URL + Search tools and commands Ieškoti įrankių ir komandų + Text editing line Teksto redagavimo eilutė + Text to place Tekstas, kurį reikia įdėti + Tooltip Patarimas + Words or phrases to redact Šalinami žodžiai ar frazės Searchable Image (Exact) Paveikslėlis su tiksliu atpažinimu Searching… {s} of {p} pages Ieškoma… {s} iš {p} puslapių Searching… {s}/{p} Ieškoma… {s}/{p} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 15a27becbc..f57894f6c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -44,8 +44,8 @@ pub struct LangInfo { /// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's /// `@plural` entries list one form per index. pub plural: fn(u64) -> usize, - /// The language is written right to left. egui lays text out left to right, so the catalog's - /// translations are put into display order when it loads ([`crate::bidi::display_rtl`]). + /// The language is written right to left. Catalogs stay in logical order: the egui 0.37 fork + /// owns bidi shaping/reordering (E5/E12), so preordering here would reverse Arabic twice. pub rtl: bool, catalog: OnceLock, } @@ -265,15 +265,16 @@ impl LangInfo { (0..=1000).map(self.plural).max().unwrap_or(0).saturating_add(1) } + /// Return the parsed catalogue, building it on first use and warning about entries that fall back to + /// English. fn catalog(&self) -> &Catalog { self.catalog.get_or_init(|| { - let (mut catalog, errors) = Catalog::parse(self.source, self.plural_forms()); + let (catalog, errors) = Catalog::parse(self.source, self.plural_forms()); for error in errors { log::warn!("{} interface catalog: {error}; that entry shows in English", self.code); } - if self.rtl { - catalog.map_translations(crate::bidi::display_rtl); - } + // Catalogs keep logical text: the fork shapes and reorders each run once. Accessibility + // names, copied text and search therefore see the same logical string as the source. catalog }) } @@ -325,8 +326,17 @@ impl Lang { /// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in /// code point order: Greek, Cyrillic, Arabic, then the CJK languages. pub fn for_picker() -> Vec { + Self::for_picker_by(Lang::name) + } + + /// [`for_picker`](Self::for_picker), ordered by the names the picker actually shows. + /// + /// `shown` must return each language's displayed name. Pass [`Lang::picker_name`] to keep a + /// Latin-script fallback with the other Latin names when a script's face is missing, rather + /// than sorting it into a block of unrenderable labels. + pub fn for_picker_by(mut shown: impl FnMut(Lang) -> &'static str) -> Vec { let mut rest: Vec = Lang::all().filter(|l| l.code() != Lang::EN.code()).collect(); - rest.sort_by_cached_key(|l| name_sort_key(l.name())); + rest.sort_by_cached_key(|l| name_sort_key(shown(*l))); std::iter::once(Lang::EN).chain(rest).collect() } @@ -334,8 +344,61 @@ impl Lang { self.0.name } - /// Whether the language is written right to left. Its translations come back from the - /// lookups in display order (see [`LangInfo::rtl`]). + /// The language's name in English, used by [`picker_name`](Self::picker_name) when this + /// build's fonts cannot draw [`name`](Self::name). + pub fn english_name(self) -> &'static str { + match self.code() { + "en" => "English", + "ja" => "Japanese", + "zh-hans" => "Simplified Chinese", + "zh-hant" => "Traditional Chinese", + "cs" => "Czech", + "pt-br" => "Portuguese (Brazil)", + "de" => "German", + "es" => "Spanish", + "fr" => "French", + "ru" => "Russian", + "kk" => "Kazakh", + "bg" => "Bulgarian", + "te" => "Telugu", + "hu" => "Hungarian", + "uk" => "Ukrainian", + "it" => "Italian", + "ar" => "Arabic", + "ckb" => "Central Kurdish", + "fa" => "Persian", + "pl" => "Polish", + "sl" => "Slovenian", + "el" => "Greek", + "tr" => "Turkish", + "lt" => "Lithuanian", + "id" => "Indonesian", + "vi" => "Vietnamese", + "fi" => "Finnish", + "nb" => "Norwegian Bokmål", + "sq" => "Albanian", + "th" => "Thai", + "ca" => "Catalan", + "ro" => "Romanian", + "lo" => "Lao", + "hi" => "Hindi", + "ko" => "Korean", + // Only reached if a language is added to `LANGUAGES` without an English name; the + // code still tells the reader which entry it is. + _ => self.0.code, + } + } + + /// The name to show in the language picker: the native name when the current fonts can draw + /// it, otherwise the English name. With egui 0.37's default `MissingGlyphPolicy::Panic`, a + /// native name whose script has no face (for example Hindi in a build without a Devanagari + /// face and with installed fonts turned off) would otherwise abort while the list opens. + pub fn picker_name(self, drawable: impl FnOnce(&str) -> bool) -> &'static str { + if drawable(self.name()) { self.name() } else { self.english_name() } + } + + /// Whether the language is written right to left. Lookups stay logical; the egui fork + /// shapes and reorders each run for display (see [`LangInfo::rtl`]). pub fn rtl(self) -> bool { self.0.rtl } @@ -711,6 +774,20 @@ mod tests { set_current(previous); } + /// Regression test: a language picker uses its native name only when a face can draw it, otherwise its + /// English name. + #[test] + fn picker_names_fall_back_to_english_when_their_script_has_no_face() { + let hi = Lang::from_code("hi").expect("hi registered"); + assert_eq!(hi.picker_name(|_| true), "हिन्दी", "the native name is kept when it can be drawn"); + assert_eq!(hi.picker_name(|_| false), "Hindi", "no Devanagari face: an English name, not an unrenderable label"); + assert_eq!(Lang::EN.picker_name(|_| false), "English"); + for language in Lang::all() { + assert!(!language.english_name().is_empty(), "{}", language.code()); + assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code()); + } + } + #[test] fn tags_map_to_languages() { assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); @@ -1694,25 +1771,24 @@ mod tests { assert_eq!(restored.language, "ar"); } - /// Lookups return Arabic in display order: egui draws the words left to right as given, and - /// joins the letters of each word itself. + /// Catalog lookups stay in logical order; the egui 0.37 fork owns bidi shaping and display + /// reordering (E5/E12), so preordering here would reverse Arabic twice. Accessibility names, + /// copied labels and search therefore see the source string. #[test] - fn arabic_lookups_are_in_display_order() { + fn arabic_catalog_stays_logical() { let ar = arabic(); - // "حفظ باسم…" as typed; the ellipsis ends the phrase on the left. - assert_eq!(tr(ar, "Save as…"), "…باسم حفظ"); - // Latin names keep their own direction inside the phrase. - assert_eq!(tr(ar, "About PdfCraft"), "PdfCraft حول"); - // A caption's colon stays beside the widget on its right. + assert_eq!(tr(ar, "Save as…"), "حفظ باسم…"); + assert_eq!(tr(ar, "About PdfCraft"), "حول PdfCraft"); assert_eq!(tr(ar, "Name:"), "الاسم:"); - assert_eq!(tr(ar, "Issued by: "), "عن صادرة: "); - // Placeholders survive and are filled in place. - assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "{name}: {e} فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "a {e}.pdf: x فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "12 من 3 الصفحة"); + assert_eq!(tr(ar, "Issued by: "), "صادرة عن: "); + // Placeholders survive in their logical positions. + assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "تعذّر فتح {name}: {e}"); + assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "تعذّر فتح a {e}.pdf: x"); + assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "الصفحة 3 من 12"); set_current(ar); - assert_eq!(command_label("Undo Insert pages from a.pdf"), "a.pdf من صفحات إدراج عن التراجع"); - assert_eq!(menu_label("file.saveAs", "Save as…"), "…باسم حفظ"); + let command = command_label("Undo Insert pages from a.pdf"); + assert!(command.contains("a.pdf") && command.contains("إدراج صفحات"), "logical command label: {command:?}"); + assert_eq!(menu_label("file.saveAs", "Save as…"), "حفظ باسم…"); set_current(Lang::EN); } @@ -1744,7 +1820,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); } assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); - // Lookups return Persian (single words in display order). + // Lookups return Persian in logical order; epaint handles visual ordering. assert_eq!(tr(fa, "Save"), "ذخیره"); assert_eq!(tr(fa, "Cancel"), "لغو"); assert_eq!(tr(fa, "File"), "فایل"); diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 75c926a0e3..6f1739974a 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1020,6 +1020,15 @@ Scroll tabs right Rull faner til høyre Search comments Søk i kommentarer Search tools and commands… Søk etter verktøy og kommandoer… + +# Accessible names for inputs that have no visible label. + Added text Lagt til tekst + Link URL URL for lenken + Search tools and commands Søk etter verktøy og kommandoer + Text editing line Tekstredigeringslinje + Text to place Tekst som skal plasseres + Tooltip Verktøytips + Words or phrases to redact Ord eller fraser som skal sladdes Searchable Image (Exact) Søkbart bilde (eksakt) Searching… {s} of {p} pages Søker… {s} av {p} sider Searching… {s}/{p} Søker… {s}/{p} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7d76739499..963d717584 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -180,6 +180,15 @@ Available Dostępne Ready Gotowe Search tools and commands… Szukaj narzędzi i poleceń… + +# Accessible names for inputs that have no visible label. + Added text Dodany tekst + Link URL Adres URL łącza + Search tools and commands Szukaj narzędzi i poleceń + Text editing line Wiersz edycji tekstu + Text to place Tekst do umieszczenia + Tooltip Podpowiedź + Words or phrases to redact Słowa lub frazy do utajenia No matching tools Brak pasujących narzędzi Tool Narzędzie Command Polecenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 371c8cc142..c34ae83bc5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1705,6 +1705,15 @@ Tool Ferramenta Command Comando Search tools and commands… Pesquisar ferramentas e comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto adicionado + Link URL URL do link + Search tools and commands Pesquisar ferramentas e comandos + Text editing line Linha de edição de texto + Text to place Texto a colocar + Tooltip Dica de ferramenta + Words or phrases to redact Palavras ou frases para ocultar No matching tools Nenhuma ferramenta correspondente All tools Todas as ferramentas Read Ler diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 09b313e734..78d2298b93 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1020,6 +1020,15 @@ Scroll tabs right Derulare file la dreapta Search comments Căutare în comentarii Search tools and commands… Căutare instrumente și comenzi… + +# Accessible names for inputs that have no visible label. + Added text Text adăugat + Link URL URL-ul linkului + Search tools and commands Căutare instrumente și comenzi + Text editing line Linie de editare a textului + Text to place Text de plasat + Tooltip Sfat ecran + Words or phrases to redact Cuvinte sau expresii de anonimizat Searchable Image (Exact) Imagine în care se poate căuta (exactă) Searching… {s} of {p} pages Se caută… {s} din {p} pagini Searching… {s}/{p} Se caută… {s}/{p} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0523fe70cc..274232d586 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -177,6 +177,15 @@ Available Доступно Ready Готово Search tools and commands… Поиск инструментов и команд… + +# Accessible names for inputs that have no visible label. + Added text Добавленный текст + Link URL URL-адрес ссылки + Search tools and commands Поиск инструментов и команд + Text editing line Строка редактирования текста + Text to place Текст для размещения + Tooltip Подсказка + Words or phrases to redact Слова или фразы для скрытия No matching tools Нет подходящих инструментов Tool Инструмент Command Команда diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3a7ea48c25..420e6a5996 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1636,6 +1636,15 @@ Search Iskanje Search comments Išči po komentarjih Search tools and commands… Poišči orodja in ukaze… + +# Accessible names for inputs that have no visible label. + Added text Dodano besedilo + Link URL URL povezave + Search tools and commands Poišči orodja in ukaze + Text editing line Vrstica za urejanje besedila + Text to place Besedilo za postavitev + Tooltip Namig + Words or phrases to redact Besede ali fraze za počrnitev Searchable Image (Exact) Iskalna slika (natančna) Searching… {s} of {p} pages Iskanje… {s} od {p} strani Searching… {s}/{p} Iskanje… {s}/{p} diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 70a6fbad77..ba7c9dc5b3 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -1585,6 +1585,15 @@ Search Kërko Search comments Kërko te komentet Search tools and commands… Kërko mjete dhe komanda… + +# Accessible names for inputs that have no visible label. + Added text Tekst i shtuar + Link URL URL e lidhjes + Search tools and commands Kërko mjete dhe komanda + Text editing line Rreshti i redaktimit të tekstit + Text to place Teksti për t'u vendosur + Tooltip Këshilla e mjetit + Words or phrases to redact Fjalë ose fraza për redaktim Searchable Image (Exact) Imazh i kërkueshëm (i saktë) Searching… {s} of {p} pages Po kërkohet… {s} nga {p} faqe Searching… {s}/{p} Po kërkohet… {s}/{p} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 2c8808aa40..7f339989ee 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -161,6 +161,15 @@ comment menu Edit సవరించు Available అందుబాటులో ఉంది Ready సిద్ధం Search tools and commands… సాధనాలు, కమాండ్‌లలో వెతుకు… + +# Accessible names for inputs that have no visible label. + Added text జోడించిన టెక్స్ట్ + Link URL లింక్ URL + Search tools and commands సాధనాలు, కమాండ్‌లలో వెతుకు + Text editing line టెక్స్ట్ సవరణ పంక్తి + Text to place ఉంచాల్సిన టెక్స్ట్ + Tooltip టూల్‌టిప్ + Words or phrases to redact రెడాక్ట్ చేయాల్సిన పదాలు లేదా పదబంధాలు No matching tools సరిపోలే సాధనాలు లేవు Tool సాధనం Command కమాండ్ diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 597a0dbb9e..2c9b892ed7 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -172,6 +172,15 @@ Available พร้อมใช้งาน Ready พร้อมใช้ Search tools and commands… ค้นหาเครื่องมือและคำสั่ง… + +# Accessible names for inputs that have no visible label. + Added text ข้อความที่เพิ่ม + Link URL URL ของลิงก์ + Search tools and commands ค้นหาเครื่องมือและคำสั่ง + Text editing line บรรทัดแก้ไขข้อความ + Text to place ข้อความที่จะวาง + Tooltip คำแนะนำเครื่องมือ + Words or phrases to redact คำหรือวลีที่จะปิดทับ No matching tools ไม่มีเครื่องมือที่ตรงกัน Tool เครื่องมือ Command คำสั่ง diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d07fcc760..a2ecaea179 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -142,6 +142,15 @@ Available Kullanılabilir Ready Hazır Search tools and commands… Araçları ve komutları ara… + +# Accessible names for inputs that have no visible label. + Added text Eklenen metin + Link URL Bağlantı URL'si + Search tools and commands Araçları ve komutları ara + Text editing line Metin düzenleme satırı + Text to place Yerleştirilecek metin + Tooltip İpucu + Words or phrases to redact Karartılacak sözcükler veya ifadeler No matching tools Eşleşen araç yok Tool Araç Command Komut diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7531c9a753..9c76868839 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -182,6 +182,15 @@ Available Доступно Ready Готово Search tools and commands… Пошук інструментів і команд… + +# Accessible names for inputs that have no visible label. + Added text Доданий текст + Link URL URL-адреса посилання + Search tools and commands Пошук інструментів і команд + Text editing line Рядок редагування тексту + Text to place Текст для розміщення + Tooltip Підказка + Words or phrases to redact Слова або фрази для вилучення No matching tools Відповідних інструментів немає Tool Інструмент Command Команда diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9c375a6e2..973ba870ea 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1057,6 +1057,15 @@ theme Appearance Giao diện Scroll tabs right Cuộn các thẻ sang phải Search comments Tìm bình luận Search tools and commands… Tìm công cụ và lệnh… + +# Accessible names for inputs that have no visible label. + Added text Văn bản đã thêm + Link URL URL của liên kết + Search tools and commands Tìm công cụ và lệnh + Text editing line Dòng chỉnh sửa văn bản + Text to place Văn bản cần đặt + Tooltip Chú giải công cụ + Words or phrases to redact Từ hoặc cụm từ cần xóa Searchable Image (Exact) Ảnh có thể tìm kiếm (Chính xác) Searching… {s} of {p} pages Đang tìm… {s} trên {p} trang Searching… {s}/{p} Đang tìm… {s}/{p} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2b1f78974..d77c8f6425 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1839,6 +1839,15 @@ Tool 工具 Command 命令 Search tools and commands… 搜索工具和命令… + +# Accessible names for inputs that have no visible label. + Added text 已添加的文本 + Link URL 链接 URL + Search tools and commands 搜索工具和命令 + Text editing line 文本编辑行 + Text to place 要放置的文本 + Tooltip 提示 + Words or phrases to redact 要密文的单词或短语 No matching tools 没有匹配的工具 All tools 所有工具 Read 阅读 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ecb5ff6231..ce3b939f1d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1783,6 +1783,15 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… + +# Accessible names for inputs that have no visible label. + Added text 已新增的文字 + Link URL 連結 URL + Search tools and commands 搜尋工具與指令 + Text editing line 文字編輯行 + Text to place 要放置的文字 + Tooltip 提示 + Words or phrases to redact 要遮蔽的字詞或片語 No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index bbc2712d85..84aa706576 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -286,24 +286,66 @@ pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; /// leaves 173 pt): a taller floor would push OK off the screen again. pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; +/// Whether the interface can draw a language picker's native name. On the web the browser's glyph +/// rasterizer draws scripts no embedded face has, so the native names stay; on native, ask the +/// fonts this build installed (including an installed fallback face, unless system fonts are off). +fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = (ui, text); + true + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) + } +} + +/// The date picker's displayed name. Its languages are the interface languages, so it uses the +/// same drawable-name fallback: a native name whose script this build has no face for would +/// otherwise abort under egui 0.37's `MissingGlyphPolicy::Panic` (for example Hindi without a +/// Devanagari face and installed system fonts turned off). +fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { + match crate::i18n::Lang::from_code(l.code) { + Some(language) => language.picker_name(drawable), + None => l.name, + } +} + /// Preferences: interface language and size, appearance, identity and JavaScript. Drawn in a scroll /// area; [`preferences_buttons`] goes below it, outside the scroll, so OK is always in reach. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); ui.horizontal(|ui| { - ui.label(tl!("Interface language")); - let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); + let label = ui.label(tl!("Interface language")); + // A native name is only drawn when these fonts can draw it; otherwise the English name + // keeps the list readable instead of aborting on a missing glyph (for example Hindi + // without a Devanagari face, and installed system fonts turned off). + let (selected, picker) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let selected = crate::i18n::Lang::from_code(&app.language) + .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); + let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) + .into_iter() + .map(|language| (language, language.picker_name(drawable))) + .collect::>(); + (selected, picker) + }; let before = app.language.clone(); - egui::ComboBox::from_id_salt("interface-language").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { - ui.close(); - } - for language in crate::i18n::Lang::for_picker() { - if ui.selectable_value(&mut app.language, language.code().to_string(), language.name()).clicked() { + egui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { ui.close(); } - } - }); + for (language, name) in &picker { + if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); // Relabel the rest of this dialog in the new language right away, not next frame. if app.language != before { crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); @@ -311,24 +353,28 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok }); ui.horizontal(|ui| { use crate::ui_scale; - ui.label(tl!("Interface size")); + let label = ui.label(tl!("Interface size")); // Auto shows the size it draws at: the display's scale times the system's text scale. let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - egui::ComboBox::from_id_salt("interface-size").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { - ui.close(); - } - for p in ui_scale::PRESETS { - let factor = f32::from(p) / 100.0; - if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { - app.ui_scale = Some(factor); + egui::ComboBox::from_id_salt("interface-size") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { ui.close(); } - } - }); + for p in ui_scale::PRESETS { + let factor = f32::from(p) / 100.0; + if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { + app.ui_scale = Some(factor); + ui.close(); + } + } + }) + .response + .labelled_by(label.id); }); ui.label( egui::RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")) @@ -437,38 +483,42 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // is app-wide: every open document follows it, and documents open with it. ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Replace document colours")); + let label = ui.label(tl!("Replace document colours")); let mode = app.reading_colors.mode_name(); - egui::ComboBox::from_id_salt("reading-colors").selected_text(tl!(reading_mode_label(mode))).show_ui(ui, |ui| { - for (m, label) in [ - ("off", "Off"), - ("black-on-white", "Black on white"), - ("white-on-black", "White on black"), - ("yellow-on-black", "Yellow on black"), - ("green-on-black", "Green on black"), - ("custom", "Custom"), - ] { - if ui.selectable_label(mode == m, tl!(label)).clicked() { - match m { - "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), - "custom" => { - // Custom starts from the colours already chosen, or black on white. - let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { - pdfcraft_render::ReadingColors { params: Some(params) } - }); - app.set_reading_colors(colors); - } - // `parse` knows these names; a preset comes with its own flags. - preset => { - if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + egui::ComboBox::from_id_salt("reading-colors") + .selected_text(tl!(reading_mode_label(mode))) + .show_ui(ui, |ui| { + for (m, label) in [ + ("off", "Off"), + ("black-on-white", "Black on white"), + ("white-on-black", "White on black"), + ("yellow-on-black", "Yellow on black"), + ("green-on-black", "Green on black"), + ("custom", "Custom"), + ] { + if ui.selectable_label(mode == m, tl!(label)).clicked() { + match m { + "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), + "custom" => { + // Custom starts from the colours already chosen, or black on white. + let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { + pdfcraft_render::ReadingColors { params: Some(params) } + }); app.set_reading_colors(colors); } + // `parse` knows these names; a preset comes with its own flags. + preset => { + if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + app.set_reading_colors(colors); + } + } } + ui.close(); } - ui.close(); } - } - }); + }) + .response + .labelled_by(label.id); }); if app.reading_colors.params.is_some() { let mut changed = false; @@ -588,10 +638,20 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { } ui.horizontal(|ui| { let label = ui.label(tl!("Language")); - let interface = crate::i18n::current().name(); - let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); - let current = app.session.date_language().map(str::to_string); - let selected = DATE_LANGUAGES.iter().find(|l| current.as_deref() == Some(l.code)).map_or(follow.clone(), |l| l.name.to_string()); + // Build every label before the combo box mutably borrows `ui`, with the same drawable + // fallback the interface language picker uses. + let (follow, selected, current, names) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let interface = crate::i18n::current().picker_name(drawable); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES + .iter() + .find(|l| current.as_deref() == Some(l.code)) + .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); + let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); + (follow, selected, current, names) + }; egui::ComboBox::from_id_salt("date-language") .selected_text(selected) .width(240.0) @@ -599,9 +659,8 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { ui.close(); } - for l in &DATE_LANGUAGES { - if ui.selectable_label(current.as_deref() == Some(l.code), l.name).clicked() - && app.session.set_date_language(Some(l.code)).is_ok() + for (l, name) in &names { + if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() { ui.close(); } @@ -619,3 +678,21 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .color(t.text_muted), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_language_names_fall_back_when_the_native_name_cannot_be_drawn() { + let date_language = |code: &str| pdfcraft_engine::dates::DATE_LANGUAGES.iter().find(|l| l.code == code).expect("date language"); + let hindi = date_language("hi"); + assert_eq!(date_language_picker_name(hindi, |_| true), hindi.name, "the native name is kept when it can be drawn"); + assert_eq!(date_language_picker_name(hindi, |_| false), "Hindi", "no Devanagari face: show the English name"); + let chinese = date_language("zh-hans"); + assert_eq!(date_language_picker_name(chinese, |_| false), "Simplified Chinese"); + for language in &pdfcraft_engine::dates::DATE_LANGUAGES { + assert!(!date_language_picker_name(language, |_| false).is_empty(), "{}", language.code); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6aed5dd854..d4ee683d4a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,8 @@ // The settings object in `persist` is one `json!` literal past the default limit of 128. #![recursion_limit = "256"] +use crate::control::Host as _; + /// Whether `a` and `b` name the same file: equal, or equal once made absolute against the /// current directory (links are not resolved, as in `last_session`). pub(crate) fn same_file(a: &str, b: &str) -> bool { @@ -281,8 +283,8 @@ struct GridDrop { /// Dropped on the Pages panel (its gap decides where they go), not the page grid. panel: bool, files: Vec, - /// When to stop waiting for the pointer (egui time, seconds). - deadline: f64, + /// When to stop waiting for the pointer (monotonic: `logic` may run without a UI pass). + deadline: web_time::Instant, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -921,7 +923,7 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, - last_autosave: f64, + last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). @@ -1092,6 +1094,7 @@ pub enum OpenOutcome { } impl PdfCraftApp { + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { session: Session::new(), @@ -1257,7 +1260,7 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), - last_autosave: 0.0, + last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), dialog_seen: None, @@ -1723,7 +1726,7 @@ impl PdfCraftApp { // Where there is no telling where the pointer is during the drag, its place arrives // with the first move after the drop. ctx.request_repaint(); - self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: ctx.input(|i| i.time) + 1.0 }); + self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: web_time::Instant::now() + std::time::Duration::from_secs(1) }); } Vec::new() } @@ -1747,7 +1750,7 @@ impl PdfCraftApp { let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; let gap = match if panel { view.panel_gap } else { view.grid_gap } { Some(gap) => gap, - None if ctx.input(|i| i.time) >= deadline => usize::MAX, + None if web_time::Instant::now() >= deadline => usize::MAX, None => return ctx.request_repaint(), }; view.insert_at = Some(gap); @@ -3447,6 +3450,60 @@ impl PdfCraftApp { } } +/// Root-level panel salts whose 0.36 persisted state key changed in 0.37. +/// +/// The shared rail and side panes now own their state. The legacy `rail` and +/// `read_right_panel` entries are inert; the former `rail-nav` footer is no longer migrated. +const PANEL_SALTS: [&str; 4] = ["tab_strip", "mode_bar", "rail", "read_right_panel"]; + +/// Copy persisted 0.36 panel sizes to 0.37's parent-scope key, once, before the first panel. +/// +/// 0.36 stored [`egui::PanelState`] under the panel's own `Id::unique(salt)`; 0.37 derives it +/// from the parent `Ui`'s scope plus an `IdSalt`. A missing copy would reset a width the user +/// chose. The old entry is left untouched, so rolling back to 0.36 still finds it. +fn migrate_panel_state(ui: &egui::Ui) { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + return; + } + for salt in PANEL_SALTS { + migrate_panel_state_from(ui, salt); + } +} + +/// Copy one 0.36 panel's state to the key derived from this exact parent `Ui`. +/// +/// The parent must be the `Ui` the panel is shown in (for a nested panel, the panel's content +/// `Ui`), because 0.37's key includes that `Ui`'s scope. +pub(crate) fn migrate_panel_state_from(ui: &egui::Ui, salt: &str) { + let old = egui::Id::unique(salt); + let new = ui.scope_id().with_salt(egui::IdSalt::new(salt)); + if old == new || egui::PanelState::load(ui.ctx(), new).is_some() { + return; + } + if let Some(state) = egui::PanelState::load(ui.ctx(), old) { + ui.ctx().data_mut(|data| data.insert_persisted(new, state)); + log::debug!("migrated panel {salt} state {:?}", state.size()); + } +} + +impl PdfCraftApp { + /// The parts of [`eframe::App::raw_input_hook`] that must run before egui's input hooks: + /// publish this pass's input-target generation to the control plugin, and hand the wheel + /// input to the reduced-motion reader. + /// + /// A test host that drives the app without eframe's raw hook can call this first, so its + /// frame ordering matches production instead of hiding focus-change bugs. + #[doc(hidden)] + pub fn prepare_raw_input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // The control plugin's input hook runs next: give it this pass's input generation. + if let Some(control) = &self.control { + control.set_epoch(self.input_epoch()); + } + // Before egui reads this frame's wheel input. + self.scroll_speed.apply(ctx, raw); + } +} + impl eframe::App for PdfCraftApp { /// While files are dragged over the window the system sends no pointer moves, so egui /// would keep the place the pointer entered at: tell it where the pointer really is, so the @@ -3454,8 +3511,7 @@ impl eframe::App for PdfCraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // The button's release a window move or resize kept from egui. window_controls::release_after_desktop_grab(ctx, raw); - // Before egui reads this frame's wheel input. - self.scroll_speed.apply(ctx, raw); + self.prepare_raw_input(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { return; } @@ -3515,8 +3571,8 @@ impl eframe::App for PdfCraftApp { self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some(); // Also done in `raw_input_hook`, before input; here for hosts that don't call the hook. self.scroll_speed.set_line_speed(ctx); - // Before anything reads this frame's wheel input or draws, and with the line speed just set. - self.motion.begin_pass(ctx, self.animation_effects); + // Motion reads this frame's wheel input, so it begins in `ui`, in a known UI phase: + // `logic` also runs alone while hidden, where the input events and time are stale. // Before taking this frame's drop: the grid must be drawn once with the pointer where // the files were let go before the gap is read. self.finish_grid_drop(ctx); @@ -3600,9 +3656,9 @@ impl eframe::App for PdfCraftApp { } self.guard_quit(ctx); // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). + #[cfg(target_os = "macos")] craft_quit::on_close_request(ctx); - let now = ctx.input(|i| i.time); - self.autosave_tick(ctx, now); + self.autosave_tick(ctx, web_time::Instant::now()); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); @@ -3651,11 +3707,17 @@ impl eframe::App for PdfCraftApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.language)); + // A language change switches stock chrome between LTR and RTL before anything is drawn. + theme::sync_direction(&ctx); // Fonts registered via set_fonts only take effect next frame; named families would panic now. if !self.fonts_ready { ctx.request_repaint(); return; } + // Before any widget reads this frame's wheel input or draws. + self.motion.begin_pass(&ctx, self.animation_effects); + // One-time 0.36 -> 0.37 panel persistence migration, before any panel stores or reads state. + migrate_panel_state(ui); for view in &mut self.views { view.begin_render_frame(); } @@ -3688,6 +3750,10 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } return; } self.sync_window_frame(&ctx); @@ -3722,6 +3788,11 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + // Screenshots are issued only after a settled pass that really drew the window. + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } } } @@ -3751,3 +3822,75 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod panel_migration_tests { + use super::*; + + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(width, 900.0)) } + } + + /// 0.36 stored a panel's persisted size under `Id::unique(salt)`. 0.37 derives the key from + /// the root `Ui`'s scope; the upgrade must carry the chosen width over, not reset it. + #[test] + fn upgrade_from_036_panel_memory_keeps_width() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + ctx.data_mut(|d| d.insert_persisted(old, state(287.0))); + let mut migrated = None; + let mut new_id = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate_panel_state(ui); + new_id = Some(ui.scope_id().with_salt(egui::IdSalt::new("rail"))); + migrated = egui::PanelState::load(ui.ctx(), new_id.expect("an id")); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the 0.36 panel width migrates to the 0.37 key"); + assert!((migrated.size().x - 287.0).abs() <= 1.0, "width survives: {:?}", migrated.size()); + assert_eq!(new_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail"))); + assert!(egui::PanelState::load(&ctx, old).is_some(), "the old key stays for rollback"); + } + + /// A synthetic nested panel is keyed from its own parent `Ui`, not the root. + /// Migrating it at the root would silently miss its 0.36 state; the fixture uses + /// legacy rail names but does not model the current shared rail implementation. + #[test] + fn nested_panel_state_migrates_from_its_actual_parent_ui() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail-nav"); + ctx.data_mut(|d| d.insert_persisted(old, state(140.0))); + let mut nested_id = None; + let mut migrated = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Panel::right("rail").resizable(false).exact_size(48.0).show(ui, |ui| { + migrate_panel_state_from(ui, "rail-nav"); + // The key egui will use for the nested panel shown in this same Ui. + let key = ui.scope_id().with_salt(egui::IdSalt::new("rail-nav")); + nested_id = Some(key); + migrated = egui::PanelState::load(ui.ctx(), key); + egui::Panel::bottom("rail-nav").resizable(false).show(ui, |_ui| {}); + }); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the nested 0.36 state migrates to its parent-scoped key"); + assert_eq!(migrated.size().x, 140.0); + assert_ne!(nested_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail-nav")), "the nested key is not the root key"); + } + + /// The one-time copy must never overwrite state a 0.37 run has already stored. + #[test] + fn migration_does_not_replace_a_037_panel_state() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + let new = ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail")); + ctx.data_mut(|d| { + d.insert_persisted(old, state(287.0)); + d.insert_persisted(new, state(412.0)); + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| migrate_panel_state(ui)); + output.drop_without_applying_deltas(); + let kept = egui::PanelState::load(&ctx, new).expect("the 0.37 state"); + assert_eq!(kept.size().x, 412.0); + } +} diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 446de39fc0..f7768912dd 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -291,7 +291,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.add_enabled_ui(d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")); + ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")).accessible_name(tl!("Link URL")); }); }); widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view")); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 00dad4bd5f..79670ad304 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -353,6 +353,7 @@ fn finish(state: &mut MeasureView, pending: &mut Option, doc: &Document, p pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { egui::ScrollArea::vertical().id_salt("measure-panel").show(ui, |ui| panel_body(app, ui, t)); } +/// Draw the measurement panel: tool selection, scale calibration, snapping and the current reading. fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { crate::widgets::begin_dialog(ui); let Some((index, id)) = app.active_ids() else { @@ -406,8 +407,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.label(tl!("Click the first point to begin.")); } ui.horizontal(|ui| { - ui.label(tl!("Label")); - ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)); + let label = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)).labelled_by(label.id); }); ui.separator(); ui.add(widgets::checkbox(&mut state.snap_enabled, tl!("Snap to drawing"))); @@ -429,7 +430,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.horizontal(|ui| { ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); - typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Scale"))); state.ratio_dirty |= typed.changed(); if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { match parse_ratio(&state.ratio_text) { @@ -459,8 +460,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::DragValue::new(&mut state.real_distance).range(0.000001..=1e12)); }); ui.horizontal(|ui| { - ui.label(tl!("Unit")); - ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); + let unit = ui.label(tl!("Unit")); + ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)).labelled_by(unit.id); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. @@ -480,8 +481,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } ui.horizontal(|ui| { - ui.label(tl!("Viewport")); - ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)); + let viewport = ui.label(tl!("Viewport")); + ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)).labelled_by(viewport.id); }); let apply = ui.add_enabled(doc.allows_annotation(), egui::Button::new(tl!("Apply scale"))).clicked(); if let Some(error) = &state.error { diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index 934fc06188..d95d205b0f 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -88,11 +88,19 @@ pub struct Motion { /// egui's own animation time and scroll animation, kept while they are changed here. egui_times: Option<(f32, ScrollAnimation)>, wheel: WheelReader, + /// This frame's wheel motion, read on its first pass and reused on later passes. + /// + /// egui empties `RawInput` between the passes of a multi-pass frame, so reading the wheel + /// again in a later pass sees nothing. Only the first pass is handed the motion (a scroll + /// area keeps the offset it moved to, a view keeps the zoom it set); later passes are handed + /// no scroll and a unit zoom, explicitly. + frame: Option<(u64, WheelMotion)>, } impl Default for Motion { + /// Start motion handling with animations enabled and no desktop preference sampled yet. fn default() -> Self { - Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default() } + Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default(), frame: None } } } @@ -114,13 +122,36 @@ impl Motion { if animate { // A gesture begun while easing is egui's to finish. self.wheel = WheelReader::default(); + self.frame = None; return; } let options = ctx.options(|o| o.input_options); - let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); - ctx.input_mut(|i| i.smooth_scroll_delta = motion.scroll); + // One frame may run several passes (`request_discard`). Only the first pass has this + // frame's events, and only it may hand the wheel on: a scroll area applies the delta to + // its persisted offset, and a view multiplies the zoom into its persisted zoom, so + // replaying either would apply it twice. egui also rebuilds `smooth_scroll_delta` and + // its zoom factor from its retained wheel state every pass, so later passes must be + // given zero scroll and a unit zoom explicitly. A missing zoom entry would not do: it + // would fall back to egui's rebuilt factor. + let frame = ctx.cumulative_frame_nr(); + let first_pass = match self.frame { + Some((read_in, _)) => read_in != frame, + None => true, + }; + let motion = match self.frame { + Some((read_in, motion)) if read_in == frame => motion, + _ => { + let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); + self.frame = Some((frame, motion)); + motion + } + }; + let (scroll, zoom) = if first_pass { (motion.scroll, motion.zoom) } else { (Vec2::ZERO, 1.0) }; + ctx.input_mut(|i| i.smooth_scroll_delta = scroll); let pass = ctx.cumulative_pass_nr(); - ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, motion.zoom))); + ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, zoom))); + // Existing kinetic scrolling remains owned by egui; gating wheel input does not + // suppress a scroll area's per-pass advancement of retained velocity. } /// egui's animations, and its scrolling to a place, take no time unless the window is to @@ -148,8 +179,9 @@ pub(crate) fn animated(ctx: &egui::Context) -> bool { ctx.global_style().animation_time > 0.0 } +/// Return the stable egui id holding the last wheel-zoom input state. fn zoom_id() -> egui::Id { - egui::Id::new("pdfcraft-motion-wheel-zoom") + egui::Id::unique("pdfcraft-motion-wheel-zoom") } /// This pass's zoom factor from a pinch or ⌘/Ctrl with the wheel (1.0 for none): egui's @@ -194,7 +226,7 @@ impl WheelReader { let mut modifiers = self.touch.unwrap_or_default(); for event in events { match event { - Event::MouseWheel { unit, delta: step, phase, modifiers: latest } => match phase { + Event::MouseWheel { unit, delta: step, phase, modifiers: latest, .. } => match phase { TouchPhase::Start => { self.touch = Some(*latest); modifiers = *latest; @@ -256,7 +288,7 @@ mod tests { use std::rc::Rc; fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { - Event::MouseWheel { unit, delta, phase, modifiers } + Event::MouseWheel { unit, delta, phase, modifiers, source: egui::MouseWheelSource::Unknown } } fn notch(dy: f32, modifiers: Modifiers) -> Event { @@ -507,6 +539,104 @@ mod tests { }); } + /// With animation effects off, a real scroll area moves by one wheel step whether the frame + /// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already + /// applied and stored the step in the first pass, so a later pass must not apply it again, + /// and must not replay the frame's raw wheel events either. + #[test] + fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() { + fn run(two_passes: bool) -> (Vec, Vec) { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut offsets = Vec::new(); + let mut wheel_events = Vec::new(); + let raw = egui::RawInput { + time: Some(0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)], + ..Default::default() + }; + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count())); + // Taller than the 300-point viewport, so one step stays far from the scroll + // limit and a doubled step could not be hidden by clamping. + let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.set_min_size(egui::vec2(100.0, 3000.0)); + }); + offsets.push(out.state.unclamped_offset()); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass scroll regression"); + } + }); + output.drop_without_applying_deltas(); + (offsets, wheel_events) + } + + let (single, single_events) = run(false); + let (two, two_events) = run(true); + + assert_eq!(single.len(), 1, "one frame, one pass"); + assert_eq!(two.len(), 2, "the frame really ran two passes"); + assert_eq!(single_events, vec![1], "one wheel event reaches the first pass"); + assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass"); + + let step = single[0].y; + assert!(step > 0.0, "the wheel step scrolled: {single:?}"); + assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}"); + assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}"); + } + + /// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom + /// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass): + /// multiplying an accumulator by each pass's factor gives the single step either way. A + /// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it. + #[test] + fn two_pass_zoom_applies_one_step_like_a_single_pass() { + /// One frame's `zoom_delta` readings, one per pass. + fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec, two_passes: bool) -> Vec { + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events, + ..Default::default() + }; + let mut zooms = Vec::new(); + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + zooms.push(zoom_delta(ui.ctx())); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass zoom regression"); + } + }); + output.drop_without_applying_deltas(); + zooms + } + + let line = egui::InputOptions::default().line_scroll_speed; + let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] { + assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}"); + let single = { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + frame(&ctx, &mut motion, 0.1, events.clone(), false) + }; + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let two = frame(&ctx, &mut motion, 0.1, events, true); + assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}"); + assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}"); + let product = |zooms: &[f32]| zooms.iter().product::(); + assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}"); + assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}"); + assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}"); + // The next frame's wheel reading starts over; the two-pass frame's factor is gone. + let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false); + assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame"); + } + } + #[test] fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 8d7d64e47c..d495116c6c 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -36,6 +36,12 @@ struct Gesture { } impl ObjectSelection { + /// Clear any object-selection gesture and the page it was blocking. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.gesture = None; + self.blocked_page = None; + } + pub fn count(&self) -> usize { self.selected.len() } diff --git a/crates/ui-egui/src/organize_panel.rs b/crates/ui-egui/src/organize_panel.rs index b02d118012..338f64bc14 100644 --- a/crates/ui-egui/src/organize_panel.rs +++ b/crates/ui-egui/src/organize_panel.rs @@ -195,7 +195,7 @@ enum Availability { fn row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability, menu: bool) -> egui::Response { let ready = availability == Availability::Ready && ui.is_enabled(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), if ready { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ready, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ready, label)); let press = theme::Press::track(ui, &resp); if ready { press.wash(ui, rect, 6, false); diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index ebb81ab5fa..a6a318c1fd 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -144,7 +144,7 @@ mod tests { let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); seen = PagePointer::read(ui, &resp, modal); if area { - egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { ui.allocate_exact_size(OVER.size(), Sense::hover()); }); } diff --git a/crates/ui-egui/src/page_selection_ui.rs b/crates/ui-egui/src/page_selection_ui.rs index 0907232f7a..eb97b16b09 100644 --- a/crates/ui-egui/src/page_selection_ui.rs +++ b/crates/ui-egui/src/page_selection_ui.rs @@ -96,7 +96,7 @@ impl PageSelectionInput { .char_limit(16_384) .hint_text(current.saturating_add(1).to_string()), ); - field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label)); + field.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), label)); commit = ui.is_enabled() && !cancel && field.lost_focus(); let pick = crate::icons::button(ui, "chevron-down", 26.0, false, tl!("Select pages")); chosen = egui::Popup::menu(&pick).show(menu).and_then(|r| r.inner); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 4f4197927a..99174a4eba 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -19,6 +19,11 @@ struct Hit { /// Where `needle` (folded with [`pdfcraft_render::text::fold_case`]) matches `hay`, for ranking. fn score(hay: &str, needle: &str) -> Option { + score_for_direction(hay, needle, crate::i18n::current().rtl()) +} + +/// Rank logical text, accepting reordered query words for right-to-left locales. +fn score_for_direction(hay: &str, needle: &str, rtl: bool) -> Option { if needle.is_empty() { return Some(0); } @@ -26,10 +31,9 @@ fn score(hay: &str, needle: &str) -> Option { if let Some(p) = h.find(needle) { return Some(p); } - // A right-to-left language's labels are in display order (their words run the other way) - // while the query is in typing order, so several words match one by one. - if crate::i18n::current().rtl() && needle.contains(char::is_whitespace) { - let first = needle.split_whitespace().map(|word| h.find(word)).try_fold(usize::MAX, |first, p| p.map(|p| first.min(p))); + // Match the all-tools search: RTL queries may name the same words in another order. + if rtl && needle.contains(char::is_whitespace) { + let first = needle.split_whitespace().try_fold(usize::MAX, |first, word| h.find(word).map(|at| first.min(at))); if let Some(first) = first { return Some(first); } @@ -39,8 +43,9 @@ fn score(hay: &str, needle: &str) -> Option { needle.chars().all(|c| it.any(|x| x == c)).then_some(100) } +/// Draw the command palette and dispatch the chosen command or tool group. pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { - let palette_rect_id = egui::Id::new("palette_rect"); + let palette_rect_id = egui::Id::unique("palette_rect"); if !app.palette_open { return; } @@ -111,8 +116,8 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { hits.truncate(12); let (down, up) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp))); let selected = ctx.data_mut(|data| { - let id = egui::Id::new("palette_selected"); - let query_id = egui::Id::new("palette_selected_query"); + let id = egui::Id::unique("palette_selected"); + let query_id = egui::Id::unique("palette_selected_query"); let previous_query = data.get_temp::(query_id); let selected = if previous_query.as_deref() == Some(&q) { data.get_temp::(id).unwrap_or(0) } else { 0 }.min(hits.len().saturating_sub(1)); @@ -143,7 +148,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } let mut chosen: Option<(Option<&'static str>, Option<&'static str>)> = None; - let area_response = egui::Area::new(egui::Id::new("palette")) + let area_response = egui::Area::new(egui::Id::unique("palette")) .order(egui::Order::Foreground) .pivot(Align2::CENTER_TOP) .fixed_pos(egui::pos2(screen.center().x, screen.top() + 96.0)) @@ -152,13 +157,15 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(560.0); ui.horizontal(|ui| { ui.add(icons::image("search", 18.0, t.text_muted)); - let r = ui.add( - egui::TextEdit::singleline(&mut app.palette_query) - .hint_text(tl!("Search tools and commands…")) - .frame(egui::Frame::NONE) - .font(theme::regular(15.0)) - .desired_width(f32::INFINITY), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut app.palette_query) + .hint_text(tl!("Search tools and commands…")) + .frame(egui::Frame::NONE) + .font(theme::regular(15.0)) + .desired_width(f32::INFINITY), + ) + .accessible_name(tl!("Search tools and commands")); // Enter runs the selected hit while the field is focused or relinquishing focus. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -181,7 +188,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // Display is translated; matching above already considered both languages. let shown = crate::i18n::command_label(&h.label); let shown_detail = tl!(&h.detail).to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &shown)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, h.ready, &shown)); let fg = if h.ready { t.text } else { t.text_faint }; let detail_font = theme::regular(12.0); let detail_max = (rect.width() * 0.42).clamp(0.0, 220.0); @@ -226,9 +233,20 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { #[cfg(test)] mod search { - use super::score; + use super::{score, score_for_direction}; use pdfcraft_render::text::fold_case; + /// Word-order tolerance is RTL-only and still requires every requested word. + #[test] + fn rtl_palette_queries_accept_reordered_words_without_reversing_text() { + let label = "تنظيم الصفحات"; + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("تنظيم الصفحات"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("الصفحات مفقود"), true), None); + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), false), None); + assert_eq!(score_for_direction("Organize Pages", &fold_case("Pages Organize"), false), None); + } + #[test] fn commands_match_in_any_case_with_turkish_i() { assert_eq!(score("İçe aktar", &fold_case("içe")), Some(0)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 535ba2e083..bcc4123f25 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -108,9 +108,10 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, elided } +/// Draw one tool-group row with its icon, label, badge and availability hint. fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -155,6 +156,7 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { theme::hand(resp).on_hover_text(tip) } +/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels. fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { if g.id == "edit" { edit_panel(app, ui, t, g); @@ -215,7 +217,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static continue; } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(item.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(item.label))); let ready = item.availability == Availability::Ready; let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); @@ -363,7 +365,7 @@ fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static widgets::section_title(ui, tl!("Add content")); let content: Vec<_> = g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); - let more_id = egui::Id::new("edit_panel_more"); + let more_id = egui::Id::unique("edit_panel_more"); let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; for i in content.iter().take(shown) { @@ -436,7 +438,7 @@ fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: & /// item is muted and says when it ships when hovered. fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -467,7 +469,7 @@ fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { for kind in StampKind::ALL.into_iter().filter(|k| k.group() == group) { let active = app.quick_tool == crate::QuickTool::Stamp(kind); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, kind.label())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, kind.label())); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, active); let body = rect.translate(press.offset()); @@ -654,6 +656,7 @@ pub(crate) enum Nav { Flash(usize, [f32; 4]), } +/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -787,7 +790,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); - let search_id = egui::Id::new(("bookmark_search", id.0)); + let search_id = egui::Id::unique(("bookmark_search", id.0)); let mut bookmark_query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); if panel == RightPanel::Bookmarks && !info.outline.is_empty() { ui.horizontal(|ui| { @@ -863,7 +866,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } for (li, l) in info.layers.iter().enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, l.visible, &l.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.visible, &l.name)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -1196,13 +1199,14 @@ fn title_format(item: &OutlineItem, depth: usize, t: &Tokens) -> egui::TextForma egui::TextFormat { font_id, color, italics: item.italic, ..Default::default() } } +/// Draw one bookmark row with expansion, inline rename and the editable context menu. fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { if cx.searching && !cx.matches.contains(path) { return; } let depth = path.len() - 1; let indent = depth as f32 * 16.0; - let id = ui.id().with(("outline", path)); + let id = ui.scope_id().with(("outline", path)); if let Some(levels) = cx.expand { ui.data_mut(|d| d.insert_temp(id, depth < levels)); } @@ -1258,7 +1262,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte let galley = ui.fonts_mut(|f| f.layout_job(job)); let h = (galley.size().y + 12.0).max(28.0); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &item.title)); theme::Press::track(ui, &resp).wash(ui, rect, 6, false); let x0 = rect.left() + indent; if !item.children.is_empty() { @@ -1478,7 +1482,7 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie } let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); let picked = view.selected.contains(&i); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, picked, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, picked, info.clone())); // The current page keeps its heavier border; picked pages share its fill. let selected = i == view.current; let press = theme::Press::track(ui, &resp); @@ -1722,7 +1726,7 @@ fn fields( FieldKind::Unknown => "square", }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &f.name)); // A row scrolled out of view still takes clicks and keyboard focus, but isn't drawn: // laying out every row of a large form each frame took a tenth of a second a frame. let visible = ui.is_rect_visible(rect); @@ -1749,8 +1753,8 @@ fn fields( for (r, icon, earlier, tip) in [(up, "chevron-up", true, tl!("Earlier in tab order")), (down, "chevron-down", false, tl!("Later in tab order"))] { - let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click()); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name))); + let b = ui.interact(r, ui.scope_id().with((&f.name, earlier)), Sense::click()); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 9f58f5ea5e..2635693676 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -151,6 +151,13 @@ pub struct PrepareView { } impl PrepareView { + /// Cancel a preparation marquee without injecting the terminal pointer release that Escape does. + pub(crate) fn cancel_pointer_gesture(&mut self) { + cancel_marquee(self); + // Unlike Escape, control cancellation never injects a terminal pointer release. + self.ignore_release = false; + } + /// Unique field names: selecting several widgets of one radio group still edits one field. pub fn names(&self) -> Vec { let mut names = Vec::new(); @@ -1371,6 +1378,7 @@ const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1' const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; +/// Draw the field-format editor for the selected category and its options. fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { let cat = category(&d.format); let mut picked = cat; @@ -1483,8 +1491,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { ui.selectable_value(p, pr.to_string(), pr); } }); - ui.label(tl!("Custom:")); - ui.add(crate::widgets::line(p).desired_width(120.0)); + let custom = ui.label(tl!("Custom:")); + ui.add(crate::widgets::line(p).desired_width(120.0)).labelled_by(custom.id); }); } Format::Special(n) => { @@ -1508,8 +1516,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } }); ui.horizontal(|ui| { - ui.label(tl!("Mask:")); - ui.add(crate::widgets::line(m).desired_width(160.0)); + let mask = ui.label(tl!("Mask:")); + ui.add(crate::widgets::line(m).desired_width(160.0)).labelled_by(mask.id); }); ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); if idx < 4 { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index fcc8d89d97..5bf8737fcc 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -300,7 +300,7 @@ pub(crate) struct PrintTasks { fn listed_printers() -> Vec { #[cfg(target_arch = "wasm32")] { - vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None }] + vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None, description: String::new() }] } #[cfg(not(target_arch = "wasm32"))] { @@ -894,15 +894,20 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { } egui::Grid::new(("driver-group", n)).num_columns(2).spacing([10.0, 4.0]).show(ui, |ui| { for o in chunk { - ui.label(&o.label); let current = choices.get(&o.key).cloned().unwrap_or_else(|| o.default.clone()); let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); let mut pick = current.clone(); - egui::ComboBox::from_id_salt(("driver-option", &o.key)).selected_text(label_of(¤t)).width(200.0).show_ui(ui, |ui| { - for (k, l) in &o.choices { - ui.selectable_value(&mut pick, k.clone(), l.as_str()); - } - }); + let option_label = ui.label(&o.label); + egui::ComboBox::from_id_salt(("driver-option", &o.key)) + .selected_text(label_of(¤t)) + .width(200.0) + .show_ui(ui, |ui| { + for (k, l) in &o.choices { + ui.selectable_value(&mut pick, k.clone(), l.as_str()); + } + }) + .response + .labelled_by(option_label.id); if pick != current { if pick == o.default { choices.remove(&o.key); @@ -918,13 +923,19 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { }); } -fn combo(ui: &mut egui::Ui, id: &str, value: &mut T, choices: &[(T, &str)], width: f32) { +/// Draw a combo box whose accessible name does not depend on the selected value. +fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { - for (v, label) in choices { - ui.selectable_value(value, *v, *label); - } - }); + egui::ComboBox::from_id_salt(id) + .selected_text(shown) + .width(width) + .show_ui(ui, |ui| { + for (v, label) in choices { + ui.selectable_value(value, *v, *label); + } + }) + .response + .accessible_name(name); } /// Draw the dialog. `thumb` gives a page's thumbnail texture when there is one. Returns @@ -948,7 +959,7 @@ pub(crate) fn body( ui.vertical(|ui| { ui.set_width(470.0); egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Printer:")); + let printer_caption = ui.label(tl!("Printer:")); let before = d.printer.clone(); // The browser's print dialog sets copies, two-sided printing and colour itself. let browser = d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER); @@ -976,7 +987,9 @@ pub(crate) fn body( if ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")).clicked() { d.printer_picked = true; } - }); + }) + .response + .labelled_by(printer_caption.id); let tip = if spool::HAS_PRINTER_PREFERENCES { tl!("Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply.") } else { @@ -1017,10 +1030,10 @@ pub(crate) fn body( ); ui.end_row(); } - ui.label(tl!("Copies:")); + let copies_label = ui.label(tl!("Copies:")); ui.add_enabled_ui(!browser, |ui| { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); + ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)).labelled_by(copies_label.id); ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); }); @@ -1034,6 +1047,7 @@ pub(crate) fn body( combo( ui, "duplex", + tl!("Two-sided:"), &mut d.duplex, &[ (None, tl!("Printer default")), @@ -1046,14 +1060,16 @@ pub(crate) fn body( }); ui.end_row(); } - ui.label(tl!("Paper:")); + let paper_label = ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, tl!(name)); } - }); + }) + .response + .labelled_by(paper_label.id); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -1104,11 +1120,11 @@ pub(crate) fn body( ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.which, Which::All, tl!("All")); widgets::radio_value(ui, &mut d.which, Which::Current, tl!("Current page")); - widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); + let pages = widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); let r = ui.add_enabled( d.which == Which::Range, crate::widgets::line(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), - ); + ).labelled_by(pages.id); if r.gained_focus() { d.which = Which::Range; } @@ -1123,6 +1139,7 @@ pub(crate) fn body( combo( ui, "subset", + tl!("More options:"), &mut d.subset, &[(Subset::All, tl!("All pages in range")), (Subset::Odd, tl!("Odd pages only")), (Subset::Even, tl!("Even pages only"))], 150.0, @@ -1157,7 +1174,8 @@ pub(crate) fn body( if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } - ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); + ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")) + .accessible_name(tl!("Custom scale")); }); } Handling::Label => { @@ -1170,21 +1188,22 @@ pub(crate) fn body( } Handling::Poster => { ui.horizontal(|ui| { - ui.label(tl!("Tile scale:")); - ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); - ui.label(tl!("Overlap:")); - ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); + let scale_label = ui.label(tl!("Tile scale:")); + ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")).labelled_by(scale_label.id); + let overlap_label = ui.label(tl!("Overlap:")); + ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")).labelled_by(overlap_label.id); ui.add(widgets::checkbox(&mut d.cut_marks, tl!("Cut marks"))); }); } Handling::Multiple => { ui.horizontal(|ui| { ui.label(tl!("Pages per sheet:")); - combo(ui, "per-sheet", &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); + combo(ui, "per-sheet", tl!("Pages per sheet:"), &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); ui.label(tl!("Page order:")); combo( ui, "order", + tl!("Page order:"), &mut d.order, &[ (PageOrder::Horizontal, tl!("Horizontal")), @@ -1212,6 +1231,7 @@ pub(crate) fn body( combo( ui, "booklet", + tl!("Booklet subset:"), &mut d.booklet_subset, &[ (BookletSubset::BothSides, tl!("Both sides")), @@ -1221,7 +1241,7 @@ pub(crate) fn body( 130.0, ); ui.label(tl!("Binding:")); - combo(ui, "binding", &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); + combo(ui, "binding", tl!("Binding:"), &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); }); } } @@ -1237,6 +1257,7 @@ pub(crate) fn body( combo( ui, "content", + tl!("Comments & Forms"), &mut d.content, &[ (Content::Document, tl!("Document")), @@ -1257,7 +1278,7 @@ pub(crate) fn body( ui.add_enabled_ui(d.as_image, |ui| { ui.label(tl!("Resolution:")); let mut dpi = d.dpi.round() as u32; - combo(ui, "print-dpi", &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); + combo(ui, "print-dpi", tl!("Resolution:"), &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); d.dpi = f64::from(dpi); }); }); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index fb48c369de..959877de64 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -86,7 +86,7 @@ fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response } ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(label), theme::regular(14.0), t.text); let info = tl!(label).to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, info.clone())); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 128e576043..71759c4599 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -311,22 +311,32 @@ impl PdfCraftApp { } } - /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { + /// Autosave when the interval has passed (called by `logic`, which also runs while hidden). + /// + /// The deadline is monotonic (`web_time::Instant`): egui's input clock does not advance + /// during a hidden `run_logic`, so using it would stall autosave or restart its interval. + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: web_time::Instant) { if self.recovery.is_none() { return; } - if now - self.last_autosave >= AUTOSAVE_SECS { - self.last_autosave = now; + // The first tick starts the interval, as the old zero clock did: it does not snapshot + // immediately just because no deadline had been recorded yet. + let Some(last) = self.last_autosave else { + self.last_autosave = Some(now); + if self.session.has_pending_autosave() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECS)); + } + return; + }; + if now.duration_since(last).as_secs_f64() >= AUTOSAVE_SECS { + self.last_autosave = Some(now); self.autosave_now(); } // An idle native window has no input-driven frames. Wake it at the existing // autosave deadline, without keeping clean or already-snapshotted documents awake. if self.session.has_pending_autosave() { - let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); - if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { - ctx.request_repaint_after(delay); - } + let next = self.last_autosave.unwrap_or(now) + std::time::Duration::from_secs_f64(AUTOSAVE_SECS); + ctx.request_repaint_after(next.saturating_duration_since(now)); } } @@ -469,8 +479,8 @@ mod idle_tests { assert_eq!(distinct.len(), 200); } - fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { - let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: web_time::Instant) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { app.autosave_tick(ui.ctx(), now); }); // Headless timer test: acknowledge texture uploads without a GPU backend. @@ -478,6 +488,8 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops + /// waking the window. #[test] fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { let dir = TestDir::new(); @@ -487,22 +499,26 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); assert!(store.list().is_empty()); - frame(&mut app, &ctx, 60.0); + frame(&mut app, &ctx, base + Duration::from_secs(60)); assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); } + /// Check that an edit after autosave schedules the next deadline, keeps the old snapshot until then, and + /// replaces it on time. #[test] fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { let dir = TestDir::new(); @@ -512,7 +528,9 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); - frame(&mut app, &ctx, 60.0); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); + frame(&mut app, &ctx, base + Duration::from_secs(60)); let entries = store.list(); assert_eq!(entries.len(), 1); let key = entries[0].key.clone(); @@ -520,20 +538,20 @@ mod idle_tests { assert!(!app.session.has_pending_autosave()); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX); app.session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second edit".into() }).unwrap(); assert!(app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 75.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(75)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "second edit wakes by 120 seconds: {delay:?}"); assert_eq!(store.read(&key).unwrap(), first, "editing does not overwrite the snapshot before its deadline"); - frame(&mut app, &ctx, 119.0); + frame(&mut app, &ctx, base + Duration::from_secs(119)); assert_eq!(store.read(&key).unwrap(), first); - frame(&mut app, &ctx, 120.0); + frame(&mut app, &ctx, base + Duration::from_secs(120)); let entries = store.list(); assert_eq!(entries.len(), 1, "the same document replaces its existing snapshot"); assert_eq!(entries[0].key, key); @@ -545,11 +563,12 @@ mod idle_tests { assert!(app.session.get(id).unwrap().dirty); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 121.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(121)); } assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); } + /// Check that a clean document and a document with recovery disabled never schedule an autosave. #[test] fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { for recovery_enabled in [false, true] { @@ -565,9 +584,10 @@ mod idle_tests { app.session.open_new("Unsaved.pdf", bytes).unwrap(); } let ctx = egui::Context::default(); + let base = web_time::Instant::now(); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert_eq!(delay, Duration::MAX); } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 8a48b3f690..6a5bee7ea4 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -388,7 +388,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> widgets::radio_value(ui, &mut d.mode, SearchMode::Phrase, tl!("Single word or phrase")); let mut enter = false; ui.add_enabled_ui(d.mode == SearchMode::Phrase, |ui| { - let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)); + let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)).accessible_name(tl!("Text to find")); enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); ui.add_space(6.0); @@ -396,7 +396,8 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> let mut import = false; ui.add_enabled_ui(d.mode == SearchMode::Words, |ui| { egui::ScrollArea::vertical().id_salt("redact-words").max_height(120.0).show(ui, |ui| { - ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)); + ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)) + .accessible_name(tl!("Words or phrases to redact")); }); ui.horizontal(|ui| { import = ui.button(tl!("Import list…")).clicked(); diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index e5a2b453f4..ebb6a34c41 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -90,7 +90,13 @@ mod tests { fn wheel(unit: egui::MouseWheelUnit, dy: f32, command: bool) -> egui::Event { let modifiers = egui::Modifiers { command, ..egui::Modifiers::NONE }; - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + } } #[test] diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index 630ca898dc..85a9b46052 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -11,6 +11,7 @@ use crate::widgets; /// Glyphs of context shown on each side of a match. const CONTEXT: usize = 36; +/// Draw the Search panel with its query, options, progress and clickable result excerpts. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: usize) { crate::widgets::begin_dialog(ui); let searched = view.texts.len() + view.text_failed.len(); @@ -18,7 +19,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us find.in_panel = true; let l = ui.label(egui::RichText::new(tl!("What word or phrase would you like to search for?")).color(t.text_muted)); let r = ui - .add(crate::widgets::line(&mut find.query).id(egui::Id::new("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .add(crate::widgets::line(&mut find.query).id(egui::Id::unique("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .accessible_name(tl!("Search")) .labelled_by(l.id); if find.focus { r.request_focus(); diff --git a/crates/ui-egui/src/side_panels.rs b/crates/ui-egui/src/side_panels.rs index 743882e617..71c3baa648 100644 --- a/crates/ui-egui/src/side_panels.rs +++ b/crates/ui-egui/src/side_panels.rs @@ -322,7 +322,7 @@ impl PaneContent for Content<'_> { match pane { Pane::Tools => crate::panels::left_panel(self.app, ui), Pane::Side => { - ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect())); + ui.data_mut(|data| data.insert_temp(egui::Id::unique("pdfcraft-inspector-body"), ui.max_rect())); crate::panels::right_panel(self.app, ui); } } @@ -389,7 +389,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; let panels = app.side_panels; // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. - let output = PaneHost::new("pdfcraft-side-panels") + let output = PaneHost::new(egui::Id::unique("pdfcraft-side-panels")) .policy(SidePolicy::Columns) .f6(false) .labels(labels) diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 6d09115b74..8f6b2c4a0c 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -678,6 +678,7 @@ fn error(ui: &mut egui::Ui, err: &Option) { } } +/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { title(ui, "Sign with a Digital ID"); ui.label(tl!("Choose the digital ID that you want to use for signing:")); @@ -690,7 +691,7 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let selected = d.selected == Some(i); let usable = e.unusable.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, usable, selected, &e.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name)); let fill = if selected { t.accent_soft } else if resp.hovered() && usable { diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 383dc1e7a9..971683887a 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -503,6 +503,8 @@ mod tests { std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") } + /// Check that the first readable fallback candidate wins, so reversing the preference list reverses the + /// chosen face. #[test] fn the_first_readable_candidate_wins_not_the_last() { // List order *is* the preference, so a loader that scanned back-to-front would pick a @@ -517,9 +519,9 @@ mod tests { let size = |p: &Path| std::fs::metadata(p).expect("bundled face").len(); assert_ne!(size(&inter), size(&mono), "the two fixtures must be tellable apart"); let first = script_fallback(&[inter.clone(), mono.clone()], 'A').expect("Inter covers 'A'"); - assert_eq!(first.font.len() as u64, size(&inter), "the first readable candidate must win"); + assert_eq!(first.bytes().len() as u64, size(&inter), "the first readable candidate must win"); let reversed = script_fallback(&[mono.clone(), inter], 'A').expect("JetBrains Mono covers 'A'"); - assert_eq!(reversed.font.len() as u64, size(&mono), "reversing the list must reverse the pick"); + assert_eq!(reversed.bytes().len() as u64, size(&mono), "reversing the list must reverse the pick"); } #[test] @@ -663,7 +665,7 @@ mod tests { // them, as this list produces. let direct = script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).expect("the list above resolves"); let cached = devanagari_fallback().expect("devanagari_fallback() resolves where these faces are installed"); - assert_eq!(cached.font.len(), direct.font.len(), "devanagari_fallback() must load the face this list points at, not another script's"); + assert_eq!(cached.bytes().len(), direct.bytes().len(), "devanagari_fallback() must load the face this list points at, not another script's"); assert_eq!(cached.index, direct.index, "and the same face within that file"); // The advertised last resort is the biggest file on the list and reaches its target through // a compatibility symlink in `/Library/Fonts`, a folder installers and users may modify — so diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 442cd6d8e1..d9ba466b42 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting}; +use egui::epaint::text::{FallbackAlign, FontTweak, HintingTarget, SmoothHinting}; use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -122,8 +122,9 @@ impl Tokens { } } + /// Return the cached theme tokens, falling back to the light palette before the first apply. pub fn get(ctx: &egui::Context) -> Self { - ctx.data(|d| d.get_temp::(egui::Id::new("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) + ctx.data(|d| d.get_temp::(egui::Id::unique("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) } pub fn dark(&self) -> bool { @@ -230,14 +231,34 @@ pub const SYSTEM_HEBREW_FALLBACK: &str = "system-hebrew-fallback"; /// The name of the installed Hangul (Korean) face [`installed_font_definitions`] may add. pub const SYSTEM_HANGUL_FALLBACK: &str = "system-hangul-fallback"; +/// Which installed face an interface language needs, given whether the build embeds a `Hans` +/// face. +/// +/// A bundled `Hans` face serves Simplified Chinese, so the installed face stays last there. It +/// cannot serve Traditional Chinese: a Traditional interface keeps an installed `Hant` face +/// before the Japanese faces even when a Simplified-only face is embedded, or Traditional-only +/// characters would fall through to a Japanese face first. +#[cfg(not(target_arch = "wasm32"))] +fn installed_fallback_need(cjk: CjkPreference, has_embedded_hans: bool) -> crate::system_fonts::Need { + use crate::system_fonts::Need; + match cjk { + CjkPreference::SimplifiedChinese if has_embedded_hans => Need::Other, + CjkPreference::SimplifiedChinese => Need::Hans, + CjkPreference::TraditionalChinese => Need::Hant, + CjkPreference::Default => Need::Other, + } +} + /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. /// -/// - In a Chinese interface built without a `Hans` craft-fonts face (every release so far, -/// #826), it is a Chinese system face (Microsoft YaHei, PingFang, …), placed before the -/// Japanese craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), -/// and one line drawn from two faces with different vertical metrics would not sit on one -/// baseline. +/// - In a Simplified Chinese interface built without an embedded `Hans` face (#826), it is a +/// Simplified Chinese system face (Microsoft YaHei, PingFang SC, …), placed before the Japanese +/// craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), and one +/// line drawn from two faces with different vertical metrics would not sit on one baseline. +/// - A Traditional Chinese interface always uses an installed `Hant` face before the Japanese +/// faces, even when a Simplified-only `Hans` face is embedded: that face cannot supply +/// Traditional-only characters (說, 體, …). /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// @@ -268,12 +289,7 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { stack.push(name.to_owned()); } } - let need = match cjk { - _ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other, - CjkPreference::SimplifiedChinese => Need::Hans, - CjkPreference::TraditionalChinese => Need::Hant, - CjkPreference::Default => Need::Other, - }; + let need = installed_fallback_need(cjk, !pdfcraft_fonts::ui_chinese_fonts().is_empty()); if let Some(data) = fallback(need) { fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data); let japanese: Vec = pdfcraft_fonts::ui_japanese_fonts().iter().map(|f| f.name()).collect(); @@ -323,37 +339,14 @@ fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak { tweak } -/// A face's vertical metrics in em, as egui reads them: skrifa's ascent, descent (negative) and -/// line gap at the default location. `None` when the face doesn't parse. -fn vertical_metrics(bytes: &[u8], index: u32) -> Option<[f32; 3]> { - use skrifa::MetadataProvider as _; - let font = skrifa::FontRef::from_index(bytes, index).ok()?; - let m = font.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let em = f32::from(m.units_per_em); - let metrics = [m.ascent / em, m.descent / em, m.leading / em]; - (em > 0.0 && metrics.iter().all(|v| v.is_finite())).then_some(metrics) -} - -/// How far below the primary face's baseline egui draws a fallback face's, in em of the font size -/// (negative: above). egui centres the fallback's line box (ascent − descent + gap) on the -/// primary's rather than lining up their baselines, so the baseline moves by half the difference -/// of ascent + descent, less half the difference of the line gaps (epaint `text_layout`). -fn baseline_drop(primary: [f32; 3], fallback: [f32; 3]) -> f32 { - let [pa, pd, pg] = primary; - let [a, d, g] = fallback; - ((a + d) - (pa + pd) + pg - g) / 2.0 -} - -/// `data` with its glyphs moved onto Inter's baseline (#1676). Without this, Chinese next to Latin -/// in one label sits higher or lower than the Latin by a few percent of the font size, depending on -/// the installed Chinese face (PingFang, Microsoft JhengHei, SimSun…). Inter is the first face of -/// every proportional family; the monospace family's JetBrains Mono differs from it by 0.004 em, -/// well under a pixel. A face that doesn't parse is left as it is (egui will skip it anyway). +/// `data` with its glyphs placed on the row's baseline (#1676). epaint 0.37 otherwise centres a +/// fallback face's line box on the primary's, so Chinese next to Latin in one label sits higher or +/// lower than the Latin by a few percent of the font size, depending on the installed or embedded +/// face (PingFang, Microsoft JhengHei, LXGW WenKai Lite…). `FallbackAlign::Baseline` makes epaint +/// position every face's glyphs from the primary face's ascent, which is exactly the existing +/// baseline-alignment intent without metric-derived offsets that go stale across egui versions. pub(crate) fn on_interface_baseline(mut data: FontData) -> FontData { - let primary = vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0); - if let (Some(primary), Some(fallback)) = (primary, vertical_metrics(&data.font, data.index)) { - data.tweak.y_offset_factor = -baseline_drop(primary, fallback); - } + data.tweak.fallback_align = FallbackAlign::Baseline; data } @@ -429,13 +422,15 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +/// Install the palette, widget styling, text sizes and right-to-left flag for the chosen theme. pub fn apply(ctx: &egui::Context, kind: ThemeKind) { // Keep the native window inherited. A concrete egui theme pins macOS's // NSWindow appearance and prevents winit from delivering ThemeChanged. // Our saved choice owns the palette; both egui branches receive it below. ctx.set_theme(egui::ThemePreference::System); + let rtl = crate::i18n::current().rtl(); let t = Tokens::for_kind(kind); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("pdfcraft-theme"), t)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("pdfcraft-theme"), t)); let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.card; @@ -477,6 +472,9 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { } ctx.set_visuals(v); ctx.global_style_mut(|s| { + // RTL languages mirror stock chrome (menus, combo boxes, scroll bars, headers). + // Labels still get their paragraph direction explicitly from `bidi::ui_direction`. + s.right_to_left = rtl; s.spacing.item_spacing = egui::vec2(8.0, 6.0); s.spacing.button_padding = egui::vec2(10.0, 5.0); s.spacing.menu_margin = egui::Margin::same(6); @@ -493,6 +491,30 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { let style = ctx.global_style(); ctx.set_style_of(egui::Theme::Light, style.clone()); ctx.set_style_of(egui::Theme::Dark, style); + ctx.data_mut(|d| d.insert_temp(direction_key(), rtl)); +} + +/// Return the stable egui id holding the current right-to-left style flag. +fn direction_key() -> egui::Id { + egui::Id::unique("pdfcraft-style-right-to-left") +} + +/// Keep stock egui chrome's direction in step with the drawing language, after a language +/// change as well as at startup. Setting a flag on both concrete themes is cheap; this avoids +/// rebuilding the whole visuals and font state every frame. +pub fn sync_direction(ctx: &egui::Context) { + let rtl = crate::i18n::current().rtl(); + let changed = ctx.data_mut(|d| { + if d.get_temp::(direction_key()) == Some(rtl) { + false + } else { + d.insert_temp(direction_key(), rtl); + true + } + }); + if changed { + ctx.all_styles_mut(|s| s.right_to_left = rtl); + } } /// WCAG 2 contrast ratio between two opaque colours. @@ -520,6 +542,21 @@ pub fn readable(color: Color32, background: Color32, text: Color32) -> Color32 { mod tests { use super::*; + /// Arabic and Kurdish switch stock chrome to mirror from the right; English switches it + /// back. This is the `Style::right_to_left` half of the explicit RTL policy. + #[test] + fn arabic_chrome_is_right_to_left_after_the_language_changes() { + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + crate::i18n::set_current(crate::i18n::Lang::from_code("ar").expect("registered language")); + apply(&ctx, ThemeKind::Light); + assert!(ctx.global_style().right_to_left); + crate::i18n::set_current(crate::i18n::Lang::EN); + sync_direction(&ctx); + assert!(!ctx.global_style().right_to_left); + crate::i18n::set_current(previous); + } + /// #3193: a bookmark's own colour is kept where it reads on the panel, and moved towards the /// theme's text colour where it doesn't (dark blue on the dark panel), never past it. #[test] @@ -641,18 +678,16 @@ mod tests { ) } - /// The baseline of each glyph of `text` laid out by egui at `size` points, in points from the - /// top of the row: its position plus its face's `y_offset_factor`, as epaint draws it. + /// The y position of each glyph of `text` laid out by egui at `size` points, in points from + /// the top of the row. fn baselines(fallback: FontData, size: f32, text: &str) -> Vec { - let factor = fallback.tweak.y_offset_factor; let mut defs = FontDefinitions::empty(); defs.font_data.insert("Inter".into(), Arc::new(FontData::from_static(pdfcraft_fonts::INTER_REGULAR))); defs.font_data.insert("fallback".into(), Arc::new(fallback)); defs.families.insert(FontFamily::Proportional, vec!["Inter".into(), "fallback".into()]); let mut fonts = Fonts::new(TextOptions::default(), defs); let g = galley(&mut fonts, FontFamily::Proportional, size, 1.0, text); - let inter_has = |c: char| c.is_ascii(); - g.rows[0].glyphs.iter().map(|gl| gl.pos.y + if inter_has(gl.chr) { 0.0 } else { size * factor }).collect() + g.rows[0].glyphs.iter().map(|gl| gl.pos.y).collect() } #[test] @@ -664,14 +699,23 @@ mod tests { // As egui lays it out by itself, DŽ sits ~4 pt off H's baseline: the bug (#1676). let raw = baselines(FontData::from_static(dancing), size, "HDŽ"); assert!((raw[1] - raw[0]).abs() > 3.0, "the test fonts must differ: {raw:?}"); - let drop = baseline_drop(vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0).unwrap(), vertical_metrics(dancing, 0).unwrap()); - assert!((raw[1] - raw[0] - size * drop).abs() <= 1.0, "baseline_drop predicts egui: {raw:?} vs {drop}"); - // On the interface baseline, to the pixel. + // `FallbackAlign::Baseline` puts every face on the same baseline, to the pixel. let aligned = baselines(on_interface_baseline(FontData::from_static(dancing)), size, "HDŽ"); assert!((aligned[1] - aligned[0]).abs() <= 1.0, "{aligned:?}"); - // A face that doesn't parse is left alone rather than guessed at. - assert_eq!(on_interface_baseline(FontData::from_static(b"not a font")).tweak, FontTweak::default()); - assert_eq!(vertical_metrics(b"", 0), None); + assert_eq!(on_interface_baseline(FontData::from_static(dancing)).tweak.fallback_align, FallbackAlign::Baseline); + } + + /// The policy only exists on native platforms (`installed_font_definitions` has no installed + /// faces on wasm), so the test must not use it in a wasm test build. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn traditional_chinese_keeps_an_installed_face_when_hans_is_embedded() { + use crate::system_fonts::Need; + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, true), Need::Other); + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, false), Need::Hans); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, true), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, false), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::Default, true), Need::Other); } #[test] diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs index b9d474565e..10ccbbbaa7 100644 --- a/crates/ui-egui/src/wheel_boost.rs +++ b/crates/ui-egui/src/wheel_boost.rs @@ -313,11 +313,23 @@ mod tests { } fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::SHIFT, + source: egui::MouseWheelSource::Unknown, + } } #[test] @@ -379,6 +391,7 @@ mod tests { assert_eq!(input.page, egui::vec2(-1.0, 0.0)); } + /// Check that NaN, zero, negative or infinite scales and non-wheel events add nothing. #[test] fn invalid_scales_and_events_contribute_nothing() { use egui::MouseWheelUnit::Line; @@ -386,8 +399,13 @@ mod tests { let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false); assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}"); } - let zoom = - egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let zoom = egui::Event::MouseWheel { + unit: Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false); assert_eq!(input, FrameInput::default()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2d1f639965..01cab03314 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -27,12 +27,12 @@ fn dialog_frame(ctx: &egui::Context) -> egui::Frame { /// A modal with the card fill, a 10-point corner and room around its contents. pub fn modal(ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> egui::Modal { - egui::Modal::new(egui::Id::new(id)).frame(dialog_frame(ctx)) + egui::Modal::new(egui::Id::unique(id)).frame(dialog_frame(ctx)) } /// Show [`modal`], outline its controls, and remember the card rect for layout checks. pub fn show_modal(ctx: &egui::Context, id: &str, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { - let stored = egui::Id::new(id); + let stored = egui::Id::unique(id); modal(ctx, id).show(ctx, |ui| { begin_dialog(ui); let inner = content(ui); @@ -111,7 +111,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) }; let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); let well = rect.shrink2(vec2(2.0, 9.0)); if active { @@ -140,21 +140,26 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let text_color = if primary { Color32::WHITE } else { t.text }; + // Dialog buttons are translated UI text: an explicit RTL base keeps an Arabic label right + // to left even when its first strong character is Latin or a placeholder. + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); - let (fill, stroke, text) = if primary { - (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) + let (fill, stroke) = if primary { + (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE) } else { - (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text) + (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border)) }; ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside); if marked { resp.request_focus(); ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); } - ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text); + let body = rect.translate(press.offset()); + ui.painter().galley(body.center() - label_galley.size() / 2.0, label_galley, text_color); theme::hand(resp) } @@ -167,14 +172,15 @@ pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response { pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f32) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(13.0); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, t.text); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); - ui.painter().text(body.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); + ui.painter().galley(body.left_center() + vec2(30.0, -label_galley.size().y / 2.0), label_galley, t.text); theme::hand(resp) } @@ -182,7 +188,7 @@ pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f3 pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), placeholder)); let press = theme::Press::track(ui, &resp); let fill = press.fill(t.field, t.hover, t.pressed); let edge = t.border.lerp_to_gamma(t.text_muted, press.hover * 0.35); @@ -197,6 +203,7 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response let text_left = (rect.left() + 34.0).min(text_right); let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom())); let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint); + job.direction = crate::bidi::ui_direction(); job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() }; let text = ui.fonts_mut(|f| f.layout_job(job)); let elided = text.elided; @@ -280,11 +287,20 @@ fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui:: } } +/// Draw a panel section heading with explicit paragraph direction and letter spacing. pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + // The paragraph direction is explicit: an Arabic title beginning with Latin or a placeholder + // must not inherit a left-to-right base from its first strong character. + let mut job = egui::text::LayoutJob::simple(crate::i18n::upper_label(text), theme::semibold(10.5), t.text_faint, ui.available_width().max(0.0)); + job.direction = crate::bidi::ui_direction(); + if let Some(section) = job.sections.first_mut() { + section.format.extra_letter_spacing = 0.6; + } + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)); ui.add_space(2.0); } @@ -292,12 +308,35 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { /// at most `max_width` wide: a long file name or path stays inside its row (#425). pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into, font: egui::FontId, color: Color32, max_width: f32) { let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + // A file name or path: an Arabic name keeps its extension at the visual end, as in the tab. + job.direction = egui::epaint::text::TextDirection::LeftToRight; job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0)); let galley = ui.painter().layout_job(job); let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size()); ui.painter().galley(rect.min, galley, color); } +/// A wrapping label for a file name or folder path, laid out explicitly left to right so an +/// Arabic name keeps `.pdf` at the end. The accessibility name is the logical text. +pub fn label_ltr(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let mut job = egui::text::LayoutJob::simple(text.into(), font, color, ui.available_width().max(0.0)); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + +/// [`label_ltr`] for one table row: the name is cut with an ellipsis instead of wrapping onto a +/// second row. The logical text remains the accessibility name. +pub fn label_ltr_truncated(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let text = text.into(); + let width = ui.available_width().max(0.0); + let mut job = egui::text::LayoutJob::simple_singleline(text, font, color); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + job.wrap = egui::text::TextWrapping::truncate_at_width(width); + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + /// Text wrapped to the available width, for text people write (comments, replies). A paragraph /// in a right-to-left script (Persian, Arabic, Hebrew) reads right to left and sits against the /// right edge ([`crate::bidi::wrap`]); text without one is an ordinary wrapping label. @@ -312,11 +351,11 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co } let width = ui.available_width().max(1.0); let lines = wrapped_lines(ui, text, &font, width, truncate); - let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, ui.painter().layout_no_wrap(line.text.clone(), font.clone(), color))).collect(); + let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, wrapped_line_galley(ui, line, &font, color))).collect(); let height = galleys.iter().map(|(_, g)| g.size().y).sum(); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); // Painted text only: the accessibility name keeps the logical order. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); let mut y = rect.top(); for (rtl, galley) in galleys { let x = if rtl { rect.right() - galley.size().x } else { rect.left() }; @@ -327,6 +366,15 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co resp } +/// One wrapped line laid out with its own paragraph direction. A line beginning with a Latin +/// word inside a right-to-left comment is still part of that paragraph, so its base direction +/// must stay right to left; `Auto` would follow the Latin word instead. +fn wrapped_line_galley(ui: &egui::Ui, line: &crate::bidi::Line, font: &egui::FontId, color: Color32) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(line.text.clone(), font.clone(), color); + job.direction = if line.rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }; + ui.painter().layout_job(job) +} + const ELLIPSIS: &str = "\u{2026}"; /// Wrapped right-to-left text kept between frames. Wrapping measures many candidate lines, so @@ -351,7 +399,7 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru let mut hasher = std::collections::hash_map::DefaultHasher::new(); (text, font, width.to_bits(), ui.ctx().pixels_per_point().to_bits(), truncate).hash(&mut hasher); let key = hasher.finish(); - let cache_id = egui::Id::new("pdfcraft-rtl-wrap-cache"); + let cache_id = egui::Id::unique("pdfcraft-rtl-wrap-cache"); if let Some(hit) = ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(cache_id).lines.get(&key).cloned()) { return hit; } @@ -368,12 +416,9 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru if truncate && (lines.len() > 1 || cut) { lines.truncate(1); if let Some(line) = lines.first_mut() { - // The ellipsis goes where the line's reading ends: the left of a right-to-left line. - if line.rtl { - line.text.insert_str(0, ELLIPSIS); - } else { - line.text.push_str(ELLIPSIS); - } + // The ellipsis goes where the line's reading ends, in logical text: epaint places it + // on the left for a right-to-left line and on the right for a left-to-right one. + line.text.push_str(ELLIPSIS); } } let lines: std::sync::Arc<[crate::bidi::Line]> = lines.into(); @@ -400,9 +445,9 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Above the progress notice while a job runs. - let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::new(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; + let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::unique(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 }; - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, lift)) @@ -455,10 +500,10 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { }; let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; let animated = crate::motion::animated(ctx); - let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); + let shown = ctx.animate_value_with_time(egui::Id::unique("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); let now = ctx.input(|i| i.time); let mut cancel = false; - let area = egui::Area::new(egui::Id::new("progress-notice")) + let area = egui::Area::new(egui::Id::unique("progress-notice")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) @@ -476,7 +521,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover()); // The label above names the job; the bar carries the percentage. resp.widget_info(|| { - let mut info = egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator); + let mut info = egui::WidgetInfo::new(egui::Role::ProgressIndicator); info.value = Some(f64::from(fraction) * 100.0); info }); @@ -506,7 +551,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { } }); }); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(PROGRESS_HEIGHT), area.response.rect.height())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(PROGRESS_HEIGHT), area.response.rect.height())); ctx.request_repaint_after(std::time::Duration::from_millis(33)); cancel } @@ -555,10 +600,10 @@ pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Re let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover()); let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0); - let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click()); - let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click()); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more)); + let main = ui.interact(main_rect, ui.scope_id().with(("split-pill", label)), Sense::click()); + let arrow = ui.interact(more_rect, ui.scope_id().with(("split-pill-more", label)), Sense::click()); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), more)); let radius = CornerRadius::same(15); ui.painter().rect(rect, radius, t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 }; @@ -586,7 +631,7 @@ pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> R let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); let (fill, stroke, text) = if primary { (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) @@ -710,7 +755,7 @@ mod tests { ); output.drop_without_applying_deltas(); } - ctx.memory(|memory| memory.area_rect(egui::Id::new("toast")).unwrap()) + ctx.memory(|memory| memory.area_rect(egui::Id::unique("toast")).unwrap()) } #[test] @@ -761,7 +806,7 @@ mod tests { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let cache_len = - |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("pdfcraft-rtl-wrap-cache")).lines.len()); + |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("pdfcraft-rtl-wrap-cache")).lines.len()); let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; let long = persian.repeat(2_000); let mut first = None; @@ -775,7 +820,7 @@ mod tests { first = Some(lines); let collapsed = wrapped_lines(ui, &long, &theme::regular(13.0), 120.0, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].text.starts_with(ELLIPSIS), "{:?}", collapsed[0].text); + assert!(collapsed[0].text.ends_with(ELLIPSIS), "{:?}", collapsed[0].text); }) .drop_without_applying_deltas(); } @@ -789,6 +834,61 @@ mod tests { assert!(cache_len(&ctx) <= WRAP_CACHE_MAX, "bounded: {}", cache_len(&ctx)); } + /// Comment lines reach epaint in logical order. A mixed Persian/Latin comment preordered by + /// `bidi::wrap` would be reordered a second time by egui 0.37; the old display-order text is + /// distinctly different, so this fails if the preordering comes back. + #[test] + fn rtl_comment_lines_stay_logical_for_epaint() { + let mixed = "این نظر PDF درباره فایل است"; + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let lines = wrapped_lines(ui, mixed, &theme::regular(13.0), 1000.0, false); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, mixed, "epaint needs reading order, not display order"); + assert!(lines[0].rtl); + // Truncation appends the ellipsis logically; the line still starts with its first word. + let cut = wrapped_lines(ui, mixed, &theme::regular(13.0), 60.0, true); + assert_eq!(cut.len(), 1); + assert!(cut[0].text.starts_with("این"), "{:?}", cut[0].text); + assert!(cut[0].text.ends_with(ELLIPSIS), "{:?}", cut[0].text); + // The galley keeps logical text, so inspect positioned glyphs to prove that the + // visible ellipsis is at the left-hand reading end after epaint's bidi layout. + let galley = ui.painter().layout_no_wrap(cut[0].text.clone(), theme::regular(13.0), Color32::WHITE); + let row = &galley.rows[0]; + let ellipsis_x = row.glyphs.iter().find(|glyph| glyph.chr == '\u{2026}').expect("ellipsis glyph").pos.x; + assert!( + row.glyphs.iter().filter(|glyph| glyph.chr != '\u{2026}' && !glyph.chr.is_whitespace()).all(|glyph| glyph.pos.x > ellipsis_x), + "RTL truncation must place the ellipsis to the left: {:?}", + row.glyphs + ); + }) + .drop_without_applying_deltas(); + } + + /// A wrapped line keeps its paragraph's direction. The second line here starts with a + /// Latin word, but belongs to a right-to-left comment: `Auto` would shape it left to right + /// and put `PDF` at the visual left. Assert the actual painted positions instead. + #[test] + fn wrapped_rtl_line_starting_with_latin_keeps_its_rtl_base() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let line = crate::bidi::Line { text: "PDF درباره فایل است".to_owned(), rtl: true }; + let galley = wrapped_line_galley(ui, &line, &theme::regular(13.0), Color32::WHITE); + assert_eq!(galley.job.direction, egui::epaint::text::TextDirection::RightToLeft); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| crate::bidi::has_rtl(&c.to_string())).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "the line stays right to left: {positions:?}"); + }) + .drop_without_applying_deltas(); + } + + /// Check right-aligned wrapping and a single collapsed line whose logical text ends with an ellipsis. #[test] fn rtl_comment_wraps_against_the_right_edge() { let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; @@ -805,7 +905,7 @@ mod tests { // Collapsed: one line, its ellipsis on the left where right-to-left reading ends. let collapsed = wrapped_shapes(persian, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].2.starts_with('\u{2026}'), "{collapsed:?}"); + assert!(collapsed[0].2.ends_with('\u{2026}'), "the galley retains logical text: {collapsed:?}"); // Text without right-to-left script is a plain label, as before. let latin = wrapped_shapes("A short note", false); assert_eq!(latin.len(), 1); diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index 70df814222..9965d46ec1 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -96,7 +96,7 @@ pub fn buttons(ui: &mut egui::Ui, buttons: &[Button]) { Button::Close => tl!("Close"), }; let (rect, response) = ui.allocate_exact_size(Vec2::splat(BUTTON), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let response = response.on_hover_text(label); if ui.is_rect_visible(rect) { paint(ui.painter(), rect, button, maximized, &t, &response); @@ -203,7 +203,7 @@ pub fn resize_edges(ctx: &egui::Context) { } let screen = ctx.content_rect(); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("window_resize_edges")) + egui::Area::new(egui::Id::unique("window_resize_edges")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .pivot(Align2::LEFT_TOP) @@ -214,7 +214,7 @@ pub fn resize_edges(ctx: &egui::Context) { if rect.width() <= 0.0 || rect.height() <= 0.0 { continue; } - let response = ui.interact(rect, ui.id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); + let response = ui.interact(rect, ui.scope_id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); // Sensing only drags, a drag starts on the press itself. if response.drag_started() { desktop_grab(ui.ctx(), egui::ViewportCommand::BeginResize(direction)); @@ -231,8 +231,9 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +/// Return the stable egui id for desktop window-grab state. fn grab_id() -> egui::Id { - egui::Id::new("window_controls_desktop_grab") + egui::Id::unique("window_controls_desktop_grab") } /// Hand the pointer to the desktop to move or resize the window. On Wayland the compositor keeps diff --git a/crates/ui-egui/tests/it/actions.rs b/crates/ui-egui/tests/it/actions.rs index 8e5f37cf08..a78de3481a 100644 --- a/crates/ui-egui/tests/it/actions.rs +++ b/crates/ui-egui/tests/it/actions.rs @@ -1,12 +1,30 @@ //! Action Wizard in the real shell (egui_kittest): run a built-in action on files, create an //! action, and remember it. +use egui::accesskit::Role; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::Session; use pdfcraft_engine::actions::Step; use pdfcraft_ui_egui::PdfCraftApp; +/// The Action Wizard's name, description and step-argument boxes are named by their labels. +#[test] +fn action_editor_inputs_have_accessible_names() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(2); + assert!(h.state_mut().execute("actions.wizard")); + h.run_steps(2); + h.get_by_label("New Action…").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Action name:"); + h.get_by_role_and_label(Role::TextInput, "Description:"); +} + #[test] fn run_and_create_actions() { let dir = std::env::temp_dir().join(format!("pdfcraft-actions-ui-{}", std::process::id())); diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index 1a66723bb9..c52b2892e0 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -127,22 +127,23 @@ fn the_right_click_menu_opens_saves_and_deletes() { assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (3, 1, 3)); h.key_press(egui::Key::Escape); h.run_steps(3); - // The PDF's menu: Save writes the PDF, not the text file whose row comes last. + // Scope actions to the menu: egui nests it under its row in the accessibility tree. + // Save writes the PDF, not the text file whose row comes last. right_click(&mut h, "inner.pdf"); assert_eq!(count(&h, "Open in a new tab"), 2, "the PDF's menu offers Open"); h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); - h.get_all_by_label("Save attachment…").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Save attachment…").click(); h.run_steps(3); assert_eq!(std::fs::read(&target).unwrap(), INNER, "Save from inner.pdf's menu"); right_click(&mut h, "inner.pdf"); - h.get_all_by_label("Open in a new tab").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Open in a new tab").click(); h.run_steps(3); assert_eq!(h.state().views.len(), 2, "Open from the menu"); h.state_mut().active = Some(0); h.run_steps(3); // Delete from inner.pdf's menu leaves notes.txt, whose row's button would delete itself. right_click(&mut h, "inner.pdf"); - h.get_all_by_label("Delete attachment").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Delete attachment").click(); h.run_steps(3); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151bb..415067ad33 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -101,7 +101,7 @@ fn new_rename_reorder_indent_delete_and_undo() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] @@ -290,8 +290,8 @@ fn style_and_colour_from_the_bookmark_menu() { let submenu = |h: &mut Harness<'static, PdfCraftApp>, menu: &str, item: &str| { h.get_by_label("Intro").click_secondary(); h.run_steps(2); - // A submenu's button carries its arrow. - h.get_by_label(&format!("{menu} ⏵")).click(); + // The submenu arrow is painted as an atom; the accessible name is the menu title. + h.get_by_label(menu).click(); h.run_steps(2); h.get_by_label(item).click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index 4ed7b5778e..c6f2e3e721 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -180,6 +180,17 @@ fn ready(t: &CombineThumb) -> bool { matches!(t, CombineThumb::Ready { .. }) } +/// Wait for a modal opened by an action to be laid out and present in the accessibility tree. +/// A newly shown modal needs a pass of its own; the action's frame only sets its state. +fn wait_for_label(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + for _ in 0..30 { + if h.query_by_label(label).is_some() { + return; + } + h.run_steps(1); + } +} + /// Press, move in steps and release, as a user drags. fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { h.hover_at(from); @@ -691,6 +702,7 @@ fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); } +/// Check that right-clicking a locked card selects that card and offers Unlock for it. #[test] fn a_locked_card_offers_unlock_on_a_right_click() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); @@ -699,6 +711,7 @@ fn a_locked_card_offers_unlock_on_a_right_click() { // The menu's item comes after the toolbar's button of the same name. h.get_all_by_label("Unlock…").last().unwrap().click(); h.run_steps(2); + wait_for_label(&mut h, "Unlock secret.pdf"); h.get_by_label("Unlock secret.pdf"); assert_eq!(h.state().combine_selection(), [1], "the card right-clicked is the one unlocked"); } @@ -716,6 +729,7 @@ fn ctrl_wheel(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, dy: f32) { delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } @@ -833,6 +847,8 @@ fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { assert_eq!(h.state().combine_zoom, now); } +/// Check that wheel zoom keeps the card under the pointer fixed and never blanks its thumbnail while a +/// sharper render arrives. #[test] fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); @@ -845,6 +861,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(6); // A card fully in view, below the toolbar, with its thumbnail drawn. @@ -866,6 +883,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); // The wheel zooms smoothly over several frames; in every one the card stays put. let mut zooms = Vec::new(); @@ -976,6 +994,7 @@ fn scrolled_grid(dy: f32) -> (Harness<'static, PdfCraftApp>, f32) { delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); (h, top) @@ -1012,6 +1031,7 @@ fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { } } +/// Check that zooming out at the bottom keeps the last card in place without leaving a gap below it. #[test] fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { let (mut h, _) = scrolled_grid(20_000.0); @@ -1024,6 +1044,7 @@ fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { delta: vec2(0.0, -20_000.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); let bottom = card(&h, "f39.pdf").bottom(); @@ -1598,13 +1619,16 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.get_by_label("Preview").click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); + wait_for_label(&mut h, "Page 3 of 3"); h.get_by_label("Page 3 of 3"); - // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from - // it); nothing is selected yet, so no "Remove b.pdf" anywhere. - h.get_by_label("Remove page"); + // The preview is modal: the card's actions are not drawn behind it. + assert!(h.query_by_label("Remove page").is_none(), "the preview covers the page card's actions"); h.key_press(Key::Escape); h.run_steps(2); + // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from + // it); nothing is selected yet, so no "Remove b.pdf" anywhere. hover_card(&mut h, &page_card(4, "b.pdf")); + h.get_by_label("Remove page"); h.get_by_label("Collapse"); assert!(h.query_by_label("Remove b.pdf").is_none()); // Space on a page card that has the keyboard: that page too. @@ -1615,6 +1639,7 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds"); + wait_for_label(&mut h, "Page 2 of 3"); h.get_by_label("Page 2 of 3"); } @@ -1651,6 +1676,7 @@ fn collapsing_brings_the_files_card_into_view() { delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 6f10288612..ff82820148 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,12 +293,13 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } +/// Check that choosing Delete pages from the Pages menu reduces the document page count. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Pages ⏵").hover(); + h.get_by_label("Pages").hover(); h.run_steps(3); h.get_by_label_contains("Delete pages").click(); h.run_steps(3); @@ -306,6 +307,8 @@ fn the_pages_menu_comes_from_the_registry() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); } +/// Check that Open Recent lists a recorded file and opens it in a new active tab without replacing the +/// original. #[test] fn the_open_recent_menu_lists_files_and_opens_one() { let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id())); @@ -326,9 +329,9 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); + h.get_by_label("Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; h.get_by_label_contains("recent.pdf").click(); @@ -341,12 +344,13 @@ fn the_open_recent_menu_lists_files_and_opens_one() { assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice()); } +/// Check that Open Recent is disabled while no file has been opened. #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), @@ -358,6 +362,7 @@ fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile { pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 } } +/// Check that Clear Recent Files empties the list and disables Open Recent. #[test] fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -370,16 +375,16 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); + h.get_by_label("Open Recent").hover(); h.run_steps(3); h.get_by_label("Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -459,7 +464,7 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { h.ctx.set_os(os); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); h } @@ -530,6 +535,8 @@ fn the_about_dialog_shows_the_running_version() { h.get_by_label_contains(&format!("Version {}", env!("CARGO_PKG_VERSION"))); } +/// Check that the quit shortcut needs exact platform modifiers and that unsaved work opens the save prompt +/// instead of closing. #[test] fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { for (os, modifiers) in [ @@ -550,6 +557,9 @@ fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close)); assert_eq!(closes, should_close, "{os:?}, extra={extra:?}, dirty={dirty}"); if dirty { + // The save prompt is a modal laid out after the key pass; let that pass settle + // before looking it up. + h.run_steps(2); h.get_by_label("Cancel").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index d516330dd7..3a3169576c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1,6 +1,7 @@ //! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and //! the Comments panel. +use egui::accesskit::Role; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -542,7 +543,7 @@ fn selected_text_replace_action_opens_replacement_composer() { h.run_steps(3); h.get_by_label("Replace text").click(); h.run_steps(2); - h.get_by_label("Replacement text"); + h.get_by_role_and_label(Role::MultilineTextInput, "Replacement text"); assert!(h.state().views[0].selected_text().is_none()); } @@ -1052,7 +1053,7 @@ fn the_panel_posts_comments_and_replies() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 3ad7953b33..e18c7bf47a 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -88,6 +88,14 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +/// Let the next real frames run after a control setting that changes the interface fonts or +/// opens a modal. `ui.set` answers in the input pass, and a font change discards the pass that +/// applies it, so the modal's labels are not in the accessibility tree until those frames have +/// been laid out. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + h.run_steps(3); +} + #[test] fn comment_pin_is_reported_and_persistent() { let (mut h, c) = harness(); @@ -162,6 +170,9 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { assert_eq!(state["documents"][0]["dirty"], false); } +/// Kittest calls only `App::logic` and `App::ui`, not eframe's `raw_input_hook`, so this checks +/// the widget tree and key handling but not the production focus-generation ordering. The +/// `production_*` tests above drive `prepare_raw_input` first and cover that ordering. #[test] fn control_focus_traverses_real_widgets_and_keyboard_activates() { let (mut h, c) = harness(); @@ -183,6 +194,55 @@ fn control_focus_traverses_real_widgets_and_keyboard_activates() { assert_eq!(state["documents"][0]["dirty"], false); } +/// A frame driven in production order: eframe's raw input hook runs before egui's input hooks, so +/// the control plugin sees the focus/dialog generation produced by the previous UI pass. The +/// kittest harness does not call `raw_input_hook`, which otherwise hides focus-change bugs. +fn production_step(h: &mut Harness<'static, PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn call_production(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { + let rx = c.send(method, params); + for _ in 0..30 { + production_step(h); + if let Ok(r) = rx.try_recv() { + return r; + } + } + panic!("{method}: no reply after 30 production-ordered frames"); +} + +#[test] +fn production_key_release_survives_the_focus_change_its_press_causes() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); + let reply = call_production(&mut h, &c, "ui.key", json!({"key": "Tab"})); + reply.unwrap_or_else(|e| panic!("a Tab press that moves focus must not leave its release unapplied: {e}")); + let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"})); + assert!(sign["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "E-Sign" && w["focused"] == true), "focus moved on the press: {sign}"); + assert!(!h.ctx.input(|i| i.keys_down.contains(&egui::Key::Tab)), "the release reached egui: no stuck Tab"); +} + +#[test] +fn production_drag_releases_the_button_after_the_press_moves_focus() { + let (mut h, c) = harness_pages(2); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None }); + ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"})); + let field = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"})); + let rect = field["widgets"][0]["rect"].as_array().expect("the search field has a rect"); + let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap()); + let start = json!([x0 + 8.0, (y0 + y1) / 2.0]); + let end = json!([x1 - 8.0, (y0 + y1) / 2.0]); + call_production(&mut h, &c, "ui.drag", json!({"from": start, "to": end, "steps": 3})) + .unwrap_or_else(|e| panic!("the press's focus change must not cancel its moves and release: {e}")); + assert!(!h.ctx.input(|i| i.pointer.button_down(egui::PointerButton::Primary)), "the pointer button was released"); + assert_eq!(h.ctx.input(|i| i.modifiers), egui::Modifiers::NONE, "no modifier is left held"); +} + #[test] fn control_button_focus_preserves_document_undo_redo_shortcuts() { let (mut h, c) = harness_pages(2); @@ -209,7 +269,7 @@ fn control_text_focus_retains_text_undo_ownership() { ok(&mut h, &c, "ui.focus", json!({"id": widgets["widgets"][0]["id"]})); assert!(h.ctx.text_edit_focused()); ok(&mut h, &c, "ui.type", json!({"text": "target"})); - let search_id = egui::Id::new(("bookmark_search", h.state().views[0].id.0)); + let search_id = egui::Id::unique(("bookmark_search", h.state().views[0].id.0)); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("target")); ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]})); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("")); @@ -454,7 +514,7 @@ fn language_switch_preserves_document_and_command_ids() { let lang = i18n::Lang::from_code(code).unwrap(); h.get_by_label(i18n::tr(lang, "Menu")).click(); h.run_steps(2); - h.get_by_label(&format!("{} ⏵", i18n::tr(lang, "File"))).hover(); + h.get_by_label(i18n::tr(lang, "File")).hover(); h.run_steps(3); h.get_by_label_contains(i18n::tr(lang, "Open…")); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); @@ -716,6 +776,7 @@ fn autoscroll_cancels_on_click_wheel_focus_loss_and_pointer_exit() { delta: egui::vec2(0.0, -20.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(2); assert!(!h.state().views[0].auto_scrolling()); @@ -854,6 +915,8 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + // The command sets the dialog; its content reaches the accessibility tree in the next pass. + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); @@ -879,6 +942,7 @@ fn german_preferences_and_search_keep_command_ids() { ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" })); @@ -908,6 +972,7 @@ fn ukrainian_preferences_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "uk" })); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); for label in ["Мова інтерфейсу", "Українська"] { let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label })); assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}"); @@ -940,7 +1005,14 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); - ok(&mut h, &c, "ui.click", json!({ "label": "編集 ⏵" })); + h.run_steps(2); + // The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick + // the popup entry by its id, not by its visible text. + let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" })); + // The tool button reports its toggle state; the popup's menu button does not. + let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry"); + ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] })); + h.run_steps(2); let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); @@ -1015,7 +1087,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() { let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "about"})); + settle(&mut h); for label in ["关于", "贡献者", "模型"] { let found = ok(&mut h, &c, "ui.inspect", json!({"query": label})); assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label), "{found}"); @@ -1042,7 +1116,9 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "signature"})); + settle(&mut h); let typed = ok(&mut h, &c, "ui.inspect", json!({"query": "请输入您的签名。"})); assert!(typed["count"].as_u64().unwrap() > 0, "{typed}"); // The shell also has a Draw control; the modal's button is registered after the shell. @@ -1270,6 +1346,10 @@ fn screenshots_of_window_and_region() { let region = ok(&mut h, &c, "ui.screenshot", json!({ "region": [10, 20, 110, 70] })); assert_eq!((region["width"].as_f64().unwrap(), region["height"].as_f64().unwrap()), ((100.0 * ppp).round(), (50.0 * ppp).round())); assert!(call(&mut h, &c, "ui.screenshot", json!({ "region": [5, 5, 1, 1] })).is_err()); + // A region that cannot be inside the window is refused at admission, before a frame or slot + // is used, with an actionable message. + let outside = call(&mut h, &c, "ui.screenshot", json!({ "region": [1300, 0, 1500, 10] })).unwrap_err(); + assert!(outside.contains("inside the window"), "{outside}"); } #[test] @@ -1766,7 +1846,13 @@ fn navigation_wheel(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, delt vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(pos), - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, delta), modifiers, phase: egui::TouchPhase::Move }, + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, delta), + modifiers, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }, ], ); h.run_steps(20); @@ -1866,7 +1952,7 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { let (mut h, _c) = harness(); let area = h.state().views[0].viewport_rect(); let shown = |h: &Harness<'static, PdfCraftApp>| { - let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("page-size-hint")); + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("page-size-hint")); h.ctx.memory(|m| m.areas().visible_last_frame(&layer)) }; let away = area.center() + egui::vec2(300.0, -200.0); @@ -1947,6 +2033,7 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { delta: egui::vec2(0.0, -1200.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); assert!(h.state().views[0].current > 0, "the wheel really moved to a later page"); diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index d33ea7baac..47462fcf2b 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -33,8 +33,9 @@ fn fixture_with_info(info: Option<&str>) -> Vec { out } +/// Return the rectangle recorded for a dialog card, failing the test when the card is missing. fn card(h: &Harness<'_, PdfCraftApp>, id: &str) -> egui::Rect { - h.ctx.data(|d| d.get_temp(egui::Id::new(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) + h.ctx.data(|d| d.get_temp(egui::Id::unique(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) } fn protected() -> Vec { @@ -202,6 +203,7 @@ fn space_audit_tab_measures_again_after_an_edit() { assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}"); } +/// Check that a dialog's Close button follows the content when switching from a taller tab to a shorter one. #[test] fn dialog_buttons_follow_the_content_after_a_taller_tab() { use pdfcraft_ui_egui::{Dialog, PropsTab}; @@ -213,6 +215,10 @@ fn dialog_buttons_follow_the_content_after_a_taller_tab() { app }); h.run_steps(4); + for name in ["Navigation tab", "Page layout", "Magnification"] { + h.get_by_role_and_label(Role::ComboBox, name); + } + h.get_by_role_and_label(Role::SpinButton, "Open to page"); h.state_mut().dialog = Some(Dialog::Properties(PropsTab::Description)); h.run_steps(4); // The card shrinks back to the shorter tab: Close sits under the last row, not halfway diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index a6523184a9..9bf7f70813 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1370,7 +1370,9 @@ fn edit_menu_names_the_step_to_undo() { }); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); // submenus open on hover; their labels carry the arrow + // The popup's Edit entry and the left panel's Edit tool carry the same visible label. The + // tool reports a toggle state; the menu entry does not. + h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow h.run_steps(3); h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut h.run_steps(3); @@ -6346,6 +6348,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); let scrolled = h.state().views[0].page_screen_rect(0).unwrap(); diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs index 2f35696611..26a6f1f3d3 100644 --- a/crates/ui-egui/tests/it/editor_taps.rs +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -313,7 +313,7 @@ fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); mouse_click(h, p); assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("edit-text-line-input"))), "with the keyboard"); } /// The open paragraph's line spacing, if a paragraph is open. diff --git a/crates/ui-egui/tests/it/fonts.rs b/crates/ui-egui/tests/it/fonts.rs index 1e569230d8..d897f509bc 100644 --- a/crates/ui-egui/tests/it/fonts.rs +++ b/crates/ui-egui/tests/it/fonts.rs @@ -15,6 +15,19 @@ fn families() -> Vec { vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)] } +/// The bundled faces, with no craft-fonts and no installed system face: 0.37 gates its emoji +/// fonts behind `monochrome_emoji_fonts`, which the app and this test crate explicitly enable. +/// Text symbols must keep working with `PDFCRAFT_SYSTEM_FONTS=0`. +#[test] +fn bundled_monochrome_symbols_have_glyphs() { + let mut fonts = Fonts::new(TextOptions::default(), theme::font_definitions()); + for id in families() { + for text in ["✔", "▲", "▼"] { + assert!(fonts.has_glyphs(&id, text), "{id:?} lacks {text} with default_fonts + monochrome_emoji_fonts"); + } + } +} + /// Lays `text` out in every interface family and returns each galley's width. fn layout_widths(fonts: &mut Fonts, text: &str) -> Vec { let mut view = fonts.with_pixels_per_point(2.0); @@ -280,42 +293,60 @@ fn catalog_han(catalog: &str) -> String { han.into_iter().collect() } -/// In a Chinese interface built without a `Hans` face (every release so far, #826), a Chinese -/// face installed on this machine goes before the Japanese faces, which lack hundreds of the -/// catalog's characters, and draws every Han character of both Chinese catalogs. +/// One Chinese preference against its catalog: the installed face must be in every family, +/// before the Japanese faces, and cover every Han character the catalog uses. +/// +/// Returns false when this machine has no installed face for the preference (or system fonts +/// are off); the caller reports that as a skip. A bundled `Hans` face is never allowed to +/// stand in for Traditional Chinese. +fn installed_chinese_face_covers(cjk: theme::CjkPreference, catalog: &str, name: &str) -> bool { + let defs = theme::installed_font_definitions(cjk); + if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { + eprintln!("skipping {name}: no installed Chinese face for {cjk:?} (or PDFCRAFT_SYSTEM_FONTS=0)"); + return false; + } + for (family, stack) in &defs.families { + let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); + assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); + let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); + assert!(own < system, "{family:?}: {stack:?}"); + if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { + assert!(system < first_ja, "{family:?}: {stack:?}"); + } + } + let han = catalog_han(catalog); + assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); + assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); + } + true +} + +/// In a Simplified Chinese interface built without a `Hans` face, a Simplified Chinese face +/// installed on this machine goes before the Japanese faces, which lack hundreds of the +/// catalog's characters. #[test] -fn chinese_ui_uses_an_installed_chinese_face() { +fn simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one() { if !pdfcraft_fonts::ui_chinese_fonts().is_empty() { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: a Hans face is bundled"); + eprintln!("skipping simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one: a Hans face is bundled"); return; } - for (cjk, catalog) in [ - (theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv")), - (theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv")), - ] { - let defs = theme::installed_font_definitions(cjk); - if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: no installed Chinese face (or PDFCRAFT_SYSTEM_FONTS=0)"); - return; - } - for (family, stack) in &defs.families { - let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); - assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); - let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); - assert!(own < system, "{family:?}: {stack:?}"); - if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { - assert!(system < first_ja, "{family:?}: {stack:?}"); - } - } - let han = catalog_han(catalog); - assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in families() { - let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); - assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); - } - } - // Other languages keep the installed face last, as before. + installed_chinese_face_covers(theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv"), "Simplified"); +} + +/// A Traditional Chinese interface keeps its installed Traditional face before the Japanese +/// faces even when the build embeds a Simplified-only `Hans` face: that face cannot supply +/// Traditional-only characters, and Japanese-first fallback would otherwise win. +#[test] +fn traditional_chinese_ui_keeps_an_installed_hant_face_before_japanese() { + installed_chinese_face_covers(theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv"), "Traditional"); +} + +/// Other languages keep the installed face last. +#[test] +fn non_chinese_ui_keeps_the_installed_face_last() { let defs = theme::installed_font_definitions(theme::CjkPreference::Default); for stack in defs.families.values() { assert!(stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).is_none_or(|i| i + 1 == stack.len()), "{stack:?}"); @@ -346,7 +377,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let defs = theme::font_definitions(); assert_eq!(&defs.families[&FontFamily::Monospace][..2], ["JetBrainsMono", "Inter"]); let fallback = &defs.font_data["Inter"]; - let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); + let fallback_font = skrifa::FontRef::from_index(fallback.font.as_ref().as_ref(), fallback.index).unwrap(); let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ ("cs", include_str!("../../src/i18n/cs.tsv")), @@ -384,7 +415,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { } for name in ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"] { let data = &defs.font_data[name]; - let font = skrifa::FontRef::from_index(&data.font, data.index).unwrap(); + let font = skrifa::FontRef::from_index(data.font.as_ref().as_ref(), data.index).unwrap(); let charmap = font.charmap(); let mut missing: Vec = text.chars().filter(|c| charmap.map(*c).is_none() && (name != "JetBrainsMono" || fallback_charmap.map(*c).is_none())).collect(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 436792553a..dcaa7510b5 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -64,7 +64,7 @@ fn hovering_a_field_shows_its_tooltip() { let mut h = harness(); assert!(h.query_by_label("Your name").is_none(), "no tooltip before the hover"); rest_on_field(&mut h, "name"); - assert!(h.query_by_label("Your name").is_some(), "the name field's /TU shows on hover"); + assert!(h.query_all_by_label("Your name").next().is_some(), "the name field's /TU shows on hover"); rest_on_field(&mut h, "city"); assert!(h.query_by_label("Your name").is_none(), "another field doesn't keep the tooltip"); // Nor shows one of its own: none has been shown since the pointer left `name` (well past the @@ -84,7 +84,7 @@ fn a_read_only_fields_tooltip_shows_on_hover() { let mut h = harness(); set_city(&mut h, pdfcraft_engine::FieldProps { tooltip: Some("Where you live".into()), read_only: Some(true), ..Default::default() }); rest_on_field(&mut h, "city"); - assert!(h.query_by_label("Where you live").is_some()); + assert!(h.query_all_by_label("Where you live").next().is_some()); } /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). @@ -404,7 +404,7 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == "name").unwrap(); let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); - (rect, egui::Id::new(("form-field", s.views[0].id.0, "name", 0usize))) + (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) }; // From inside the first word to past the end of the text. let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 78a8b0b3b4..f2b6c0e907 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -300,13 +300,15 @@ fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { // --------------------------------------------------------------------------------------------- // Things drawn over the page keep the page's cursor, tips and presses off. +/// Check that the command palette's frame keeps the default cursor over the page while the page beside it +/// still shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_command_palette() { let mut h = open(link_pdf(), "300", false); stamp_tool(&mut h); h.state_mut().palette_open = true; h.run_steps(4); - let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); // On the palette's frame margin, where none of its widgets sets a cursor, over the page. let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0); assert!(page(&h).contains(p), "the palette must lie over the page here"); @@ -324,7 +326,7 @@ fn link_tip_stays_off_while_the_palette_is_open() { let mut h = open(link_pdf(), "300", false); h.state_mut().palette_open = true; h.run_steps(4); - let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); let beside = [60.0, 100.0, 150.0, 200.0, 240.0] .into_iter() .flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y))) @@ -392,21 +394,30 @@ fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() { assert!(tip_shown(&h), "and shows its tip"); } +/// Send one wheel event over the page and let its scrolling settle. fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { h.hover_at(egui::pos2(800.0, 500.0)); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); for _ in 0..10 { h.run_steps(2); } } +/// Check that the quick bar's frame keeps the default cursor over the page while the page below it still +/// shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_quick_bar() { let mut h = open(link_pdf(), "400", true); stamp_tool(&mut h); // Bring the page under the quick bar's left end. wheel(&mut h, egui::vec2(-200.0, 0.0)); - let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown"); + let bar = h.ctx.memory(|m| m.area_rect(egui::Id::unique("quick-bar"))).expect("quick bar shown"); // Inside the bar's frame margin: no button there. let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0); assert!(page(&h).contains(p), "the quick bar must lie over the page here"); @@ -435,7 +446,7 @@ fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { /// Whether the page's hover tip was drawn in the last frame. fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool { - let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover")); h.ctx.memory(|m| m.areas().is_visible(&tip)) } @@ -445,6 +456,7 @@ fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); } +/// Check that the link tip and pointing cursor disappear under the Unlock prompt. #[test] fn link_tip_stays_under_the_unlock_prompt() { let mut h = open(link_pdf(), "300", true); @@ -461,18 +473,19 @@ fn link_tip_stays_under_the_unlock_prompt() { for _ in 0..4 { h.run_steps(2); } - let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock"))); + let card = h.ctx.memory(|m| m.area_rect(egui::Id::unique("unlock"))); assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}"); assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt"); assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); } +/// Check that moving onto a link's floating tip still reports the link's pointing-hand cursor. #[test] fn the_link_tip_lets_the_pointer_through_to_the_link() { let mut h = open(link_pdf(), "150", false); let p = at(&h, 100.0, 340.0); assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); - let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip"); + let tip = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).expect("the link's tip"); // A quick move lands on the tip, drawn beside where the pointer was, and still on the link. let onto = tip.min + egui::vec2(6.0, 6.0); assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link"); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 936b847d82..806fa7153e 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1028,9 +1028,7 @@ fn escape_in_a_name_field_leaves_the_field_not_the_table() { let mut h = harness(); draw_table(&mut h); // The app's own text boxes (zoom, page number) are inputs too: take the one in the window. - let window = h.get_by_label("Text Field Table").rect(); - let field = - h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field"); + let field = h.get_by_role_and_label(Role::TextInput, "Column 1").rect(); h.hover_at(field.center()); h.drag_at(field.center()); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 21aeca1286..81025e756f 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -23,6 +23,15 @@ fn harness_with_settings(settings: Option) -> Harness<'static, PdfCraftA h } +/// The page-range field is labelled by the Pages radio button rather than floating unnamed. +#[test] +fn the_page_range_field_is_named_for_screen_readers() { + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_role_and_label(Role::TextInput, "Pages"); +} + #[test] fn current_print_page_saves_the_viewed_page_when_labels_restart() { // Five distinct painted pages, labelled i, ii, iii, 1, 2. @@ -62,7 +71,7 @@ fn current_print_page_saves_the_viewed_page_when_labels_restart() { h.run_steps(3); assert!(h.state_mut().execute("print.dialog")); h.run_steps(3); - h.get_by_label("Current page").click(); + h.get_by_role_and_label(Role::RadioButton, "Current page").click(); h.run_steps(3); assert_eq!(h.state().print_draft.current_page, current); h.get_by_label("Sheet 1 of 1"); @@ -278,8 +287,8 @@ fn printers_listed_in_the_background_preselect_the_default_unless_one_was_picked h.state_mut().printer_source = Some(Arc::new(move || { let _open = wait.lock(); vec![ - Printer { name: "Office".into(), default: false, two_sided: None }, - Printer { name: "Front Desk".into(), default: true, two_sided: None }, + Printer { name: "Office".into(), default: false, two_sided: None, description: String::new() }, + Printer { name: "Front Desk".into(), default: true, two_sided: None, description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -335,7 +344,10 @@ fn the_picked_printer_is_kept_for_the_next_print_while_it_is_listed() { let source = listed.clone(); h.state_mut().printer_source = Some(Arc::new(move || { let names = source.lock().map(|n| n.clone()).unwrap_or_default(); - names.into_iter().map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None }).collect() + names + .into_iter() + .map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None, description: String::new() }) + .collect() })); let open = |h: &mut Harness<'static, PdfCraftApp>| { h.state_mut().dialog = None; @@ -422,8 +434,8 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { let mut h = harness(); h.state_mut().printer_source = Some(std::sync::Arc::new(|| { vec![ - Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }, - Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }, + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -885,10 +897,10 @@ fn a_booklet_decides_two_sided_itself() { h.run_steps(3); h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); h.run_steps(1); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); h.get_by_label("Booklet").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_none(), "hidden while Booklet is chosen"); let d = &h.state().print_draft; assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); @@ -898,6 +910,6 @@ fn a_booklet_decides_two_sided_itself() { } h.get_by_label("Size").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 3e3f3a593c..2d8258eeda 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -173,6 +173,8 @@ fn encrypted(bytes: Vec, password: &str) -> Vec { pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +/// Check that an encrypted document is autosaved without plaintext and recovered only after the password is +/// entered. #[test] fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { let bytes = encrypted(fixture(2), "pw"); @@ -186,6 +188,13 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() h.get_by_label_contains("password-protected"); h.get_by_label("Recover").click(); support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some()); + // The prompt is laid out in the pass after the one that set it. + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } let field = h.get_by_role(Role::PasswordInput); field.focus(); field.type_text("pw"); @@ -320,6 +329,8 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi } } +/// Check that an encrypted snapshot without metadata still asks for its password and is recovered without +/// rewriting its bytes. #[test] fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let s = store("encrypted-orphan"); @@ -330,6 +341,12 @@ fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let mut h = harness(s.clone()); h.get_by_label("Recover").click(); support::wait_until(&mut h, "orphan recovery password prompt", |app| app.password_prompt.is_some()); + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } assert!(h.state().views.is_empty()); let field = h.get_by_role(Role::PasswordInput); field.focus(); diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 2b63044160..7f7358bfeb 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -189,9 +189,10 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) } +/// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); @@ -199,7 +200,7 @@ fn selected_text_copy_edit_and_context_menus() { h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); + h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); h.run_steps(3); h.get_by_label_contains("Copy").click(); let mut did_copy = false; @@ -336,6 +337,7 @@ fn selected_text_copy_preserves_unicode_and_page_clipboard() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); } +/// Check that a focused search input owns Copy and the selected page text does not also consume it. #[test] fn selected_text_copy_keeps_focused_input_clipboard_ownership() { let (mut h, at) = harness(|_| {}); @@ -344,7 +346,7 @@ fn selected_text_copy_keeps_focused_input_clipboard_ownership() { h.state_mut().views[0].open_find(); h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); h.run_steps(3); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input"))); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.run_steps(2); @@ -394,7 +396,13 @@ fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { /// One wheel event, with time to scroll (egui spreads a wheel line over a few frames). fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) { - h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + h.event(Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(10); } diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 1559838dd0..e5e930a971 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -38,10 +38,10 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme ⏵").hover(); + h.get_by_label("Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index a047e916ee..12723274d5 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -596,6 +596,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { delta: egui::vec2(-500.0, 0.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -718,6 +719,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { delta: egui::vec2(0.0, -500.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -1717,6 +1719,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { delta: egui::vec2(0.0, -200.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 01e733e4d7..28eb6fd768 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -70,7 +70,7 @@ fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> Harness<'s /// A vertical wheel event; negative `dy` scrolls down. fn wheel(unit: MouseWheelUnit, dy: f32, phase: TouchPhase) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE, source: egui::MouseWheelSource::Unknown } } #[cfg(target_os = "linux")] @@ -710,7 +710,13 @@ fn frames_after_a_notch(system_reduced: bool, animation_effects: &str, zooming: h.run_steps(2); let before = rect(&h, 0).expect("page 1 on screen").min.y; let (delta, modifiers) = if zooming { (egui::vec2(0.0, 1.0), Modifiers::COMMAND) } else { (egui::vec2(0.0, -1.0), Modifiers::NONE) }; - h.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta, + phase: TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); (0..40) .map(|_| { h.step(); @@ -1029,7 +1035,7 @@ fn view_menu_page_display_uses_the_same_commands() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -2007,15 +2013,15 @@ fn long_view_menu_fits_a_short_window() { h.run_steps(8); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(4); - let before = h.get_by_label("Side panels ⏵").rect().bottom(); + let before = h.get_by_label("Side panels").rect().bottom(); h.get_by_label("Fit to height ").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels ⏵").rect().bottom(); + let after = h.get_by_label("Side panels").rect().bottom(); assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels ⏵").hover(); + h.get_by_label("Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 915c47b41a..f2a12b69aa 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -154,7 +154,7 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(n, 15); // Surrender text edit focus so canvas shortcut handler receives key events. h.state_mut().views[0].find.as_mut().unwrap().focus = false; - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("find-input"))); h.run_steps(2); let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); // ⌘G advances to next match. @@ -201,6 +201,141 @@ fn find_shortcuts_work_from_the_find_field() { assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); } +/// Open the find bar on the first document, search for the single uppercase "PAGE" on each +/// page, and wait until every text layer has answered. Returns the document's view index. +fn find_one_match_per_page(h: &mut Harness<'static, PdfCraftApp>) -> usize { + h.state_mut().active = Some(0); + h.run_steps(2); + let view = h.state().active.expect("a document is shown"); + { + let v = &mut h.state_mut().views[view]; + v.open_find(); + let f = v.find.as_mut().expect("the find bar is open"); + f.query = "PAGE".into(); + f.case_sensitive = true; + v.rerun_find(); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[view].find.as_ref().is_some_and(|f| f.matches.len() >= 5) { + break; + } + } + assert_eq!(h.state().views[view].find.as_ref().unwrap().matches.len(), 5, "one PAGE on each of five pages"); + view +} + +/// Automatic following ends when the reader scrolls inside the viewed page, not only when the +/// page changes. This drives the production document area with a real wheel event. +#[test] +fn wheel_scrolling_ends_automatic_search_following_on_the_same_page() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + { + let f = h.state().views[view].find.as_ref().unwrap(); + assert_eq!((f.auto_page, f.follow_ended), (Some(0), false), "the automatic choice is following page 1"); + } + let center = h.state().views[view].viewport_rect().center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(12); + let v = &h.state().views[view]; + assert_eq!(v.current, 0, "a small wheel scroll stays within page 1"); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "the reader's wheel ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); +} + +/// A programmatic search flash is not reader scrolling, even while an unrelated drag is held. +/// Holding a text selection on the page keeps a pointer drag active; the flash still needs to +/// move the viewport, and automatic following must survive so a late layer can still follow it. +#[test] +fn a_search_flash_preserves_following_while_an_unrelated_drag_is_held() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let start = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").center(); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Move enough to turn the press into a text-selection drag. That drag owns the pointer but + // does not scroll the document; only the document scroll area scrolls it. + let held = start + egui::vec2(24.0, 0.0); + h.event(egui::Event::PointerMoved(held)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "an unrelated page drag is held"); + assert!(h.state().views[view].page_screen_rect(1).is_none(), "page 2 starts off screen"); + + // A flash to page 2 visibly moves the viewport from under the held drag. + { + let f = h.state_mut().views[view].find.as_mut().unwrap(); + f.query = "Page 2".into(); + f.case_sensitive = true; + } + h.state_mut().views[view].rerun_find(); + h.run_steps(3); + let v = &h.state().views[view]; + assert_eq!(v.current, 1, "the automatic choice moved to page 2"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "a flash's own jump is not the reader scrolling"); + assert_eq!(f.auto_page, Some(1)); + assert!(v.page_screen_rect(1).is_some(), "page 2 is visibly on screen after the flash"); + + // Releasing the unrelated drag leaves the automatic follow in place. + h.event(egui::Event::PointerButton { pos: held, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[view].find.as_ref().unwrap().follow_ended, "the page drag never ended following"); +} + +/// The document scroll bar is its own scroll surface: a track click moves the offset with no +/// pointer motion, and dragging from that press keeps moving it. Both are the reader scrolling, +/// so automatic following ends. A global "any drag is scrolling" check would miss the click. +#[test] +fn scroll_bar_track_click_and_drag_end_automatic_search_following() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let vp = h.state().views[view].viewport_rect(); + // Floating bars sit in the outer few points of the document area (see theme). Aim 75% down + // the vertical track: below the handle at this scroll position, and clear of the corner + // where the horizontal bar's track overlaps. + let track = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.75); + let before = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").top(); + h.event(egui::Event::PointerMoved(track)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + { + let v = &h.state().views[view]; + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a track click that jumps the view ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + let after = v.page_screen_rect(0).map(|r| r.top()); + assert!(after.is_none_or(|top| top < before - 100.0), "the track click moved the viewport ({before} -> {after:?})"); + } + + // Keep dragging from the press: the scroll bar follows the pointer, still reader scrolling. + let dragged = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.4); + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + let v = &h.state().views[view]; + assert!(v.find.as_ref().unwrap().follow_ended, "the track drag is the reader scrolling"); + assert!(v.page_screen_rect(0).is_none_or(|r| r.top() < before - 100.0), "dragging the track kept moving the viewport"); + h.event(egui::Event::PointerButton { pos: dragged, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); @@ -339,7 +474,7 @@ fn page_input_focused(h: &Harness<'static, PdfCraftApp>) -> bool { let focused = h.ctx.memory(|m| m.focused()); let view = &h.state().views[h.state().active.unwrap()]; // Also identifies the original editor when running this test-only patch on the baseline. - focused == Some(egui::Id::new(("page-input", view.id.0))) || focused == Some(egui::Id::new("page-input")) + focused == Some(egui::Id::unique(("page-input", view.id.0))) || focused == Some(egui::Id::unique("page-input")) } /// Exercise the real navigation editor, including its focus and Enter handling. @@ -967,7 +1102,7 @@ fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { let left = h.get_by_label("Left Edge").rect(); let right = h.get_by_label("Right Edge").rect(); // Properties requests a fixed width. Measure its card in each viewport. - let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + let card = h.ctx.data(|d| d.get_temp::(egui::Id::unique("dialog").with("card"))).expect("Properties is shown"); for (n, rect) in &buttons { for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); @@ -1556,7 +1691,13 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[i].page_screen_rect(0).expect("page 1 on screen").min.y; let before = top(&h); h.event(egui::Event::PointerMoved(h.state().views[i].viewport_rect().center())); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); // egui spreads a wheel notch over a few frames. h.run_steps(30); before - top(&h) @@ -1857,6 +1998,7 @@ fn mouse_wheel_scrolls_while_selecting_text() { delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(20); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1896,7 +2038,13 @@ fn selection_wheel_delta(unit: egui::MouseWheelUnit, amount: f32, held: bool, an } h.run_steps(2); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -amount), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -amount), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); before - h.state().views[view].page_screen_rect(0).unwrap().top() } @@ -1930,7 +2078,13 @@ fn selection_control_wheel_still_zooms() { for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { let (mut h, view, _) = selection_wheel_harness(); let before = h.state().views[view].page_screen_rect(0).unwrap().width(); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().width(); assert!(after > before + 1.0, "control wheel should zoom: {before} -> {after}"); @@ -1948,6 +2102,7 @@ fn selection_wheel_outside_document_does_not_scroll() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1975,6 +2130,7 @@ fn selection_wheel_does_not_scroll_behind_preferences() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1991,6 +2147,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -30.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(delay); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -2002,6 +2159,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); diff --git a/crates/ui-egui/tests/snapshots/panel_comments.txt b/crates/ui-egui/tests/snapshots/panel_comments.txt index c5bed58730..ca9e50db8c 100644 --- a/crates/ui-egui/tests/snapshots/panel_comments.txt +++ b/crates/ui-egui/tests/snapshots/panel_comments.txt @@ -4,11 +4,11 @@ Button "Close" [off] Button "More options" [off] Button "Filter comments" [off] Button "Search comments" [off] -TextInput +TextInput "Add a comment" Button "Collapse" [off] Label = "Page 1" Label = "1" Label = "Reviewer" Label = "2026-01-01 12:00" Label = "Check this heading" -Unknown +Unknown "Note — click to show it on the page" diff --git a/deny.toml b/deny.toml index ed9c26c206..f6c2028bbd 100644 --- a/deny.toml +++ b/deny.toml @@ -34,7 +34,13 @@ unknown-registry = "deny" unknown-git = "deny" # Shared UI components and spooler; Cargo.toml pins the immutable revision. craft-print pins # storytold's winprint fork (Windows printing, #1107) to an immutable revision of its own. -allow-git = ["https://github.com/storytold/craft-libs", "https://github.com/storytold/winprint.rs"] +allow-git = [ + "https://github.com/storytold/craft-libs", + "https://github.com/storytold/winprint.rs", + # The reviewed egui 0.37 and winit 0.30.13 fork revisions (immutable pins in Cargo.toml). + "https://github.com/storytold/egui", + "https://github.com/storytold/winit", +] [advisories] yanked = "warn" diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 0000000000..2871a57f7c --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,812 @@ +# Current integration — 2026-10-25 + +The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`. + +Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. + +Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. The 5,190 result comes from managed run `egui037-pdf-combined-ci-20261025-1340` and qualifies the integration of main `d764a8ebca2dc289841b34ea3048f613a9869bbb` with `Cargo.lock` SHA-256 `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`, published as `fbfad2039c650a02b39ceee5c3f0e4d6fd4b3f74`. The older 5,183-pass result covered commit `9032aeb35f7abe7777f66d4b835fd461a865c516`, integrating main `71b47b51`; it is a separate checkpoint and is not evidence for the current tree. Both runs recorded 22 ignored executions across 89 summaries. + +Remaining acceptance includes native/browser GUI, native IME, packaging and paired performance. Shared-pane F6 cycling and explicit RTL edge mirroring are not enabled. New side-pane command labels still need catalog translations; English fallback remains. Current panel checks must exercise Tools/inspector visibility, Read mode, resizing and persistence, rail selection, F4/Shift+F4, capture and accessible Pane/Splitter labels, rather than the removed floating/tab-group workflows. + +The qualification tables and original porting protocol below are historical records. Their old pins, lock hashes and docking design describe those checkpoints only; this section supersedes them for the current integration. + +> Historical records begin below this notice. Their source identities, lockfile hashes and test counts describe earlier checkpoints only; they do not qualify the current `d764a8eb` integration. The current integration and its 5,190-pass qualification are recorded above. + +# PdfCraft — egui 0.37 porting plan + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `418ac6a2`, including suppression of page link hover while dragging a panel, page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused pointer regressions plus CI recorded 5,130 passing test executions, zero failures and 28 ignored across 143 summaries. The dragged-panel link regression passes on egui 0.37; independent review accepted preservation of the upstream fix. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `24edde65`, including page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused dialog regressions plus CI recorded 5,111 passing test executions, zero failures and 22 ignored across 143 summaries. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status: implemented locally against `a2ec27b4775f53c40cad3456936d9406611e3a8d`; not published. Production code, manifests, vendor provenance and tests in this checkout carry the migration. The 2026-10-25 qualification section below supersedes the historical external-config and missing-IME statements; the 2026-10-24 amendment records the earlier local state. The manifests pin egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-ui/craft-quit/craft-print `25da1314d443171b4adeaad2cd9454b273f35c6f` directly by immutable git revision, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; `Cargo.lock` is `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`, and the target-main observation is `a7801dc84e74dd12757aecbb6be324dce7a33939`. The repository quality gates passed as recorded below; publication still requires fresh human approval. This revision also incorporates the independent adversarial review of the prior plan (`f781bf52d03d59cd2fb4539771c7a0d196e2f49e64c48635e72c224c9bdcdcd0`). + +## Historical qualification state — 2026-10-25 + +The managed run `egui037-pdfcraft-reviewed-ui-ci-20261025-0541` passed the first nine CI stages, including formatting, strict lint, workspace tests, WASM compilation, assets and dependency checks. It recorded 5,025 passing test executions, zero failures and 16 ignored across 198 summaries (including four focused regression executions). The final parity stage found one stale test reference after the Arabic logical-order test was renamed; the reference was corrected without changing executable source. Formatting and parity then passed in `egui037-pdfcraft-parity-final-20261025-0555`. An intervening submission `0553` was refused by the storage guard before execution and is not validation evidence. Final documentation/attribution corrections were checked by formatting, assets generation/validation and parity in `egui037-pdfcraft-final-doc-assets-20261025-0571`. + +The page-text editor keeps the prior four-by-two inset under egui 0.37, and print tests locate the Two-sided control by ComboBox role. Both narrow/wide text image regressions and booklet regressions passed before the full suite. Canonical dependency/source guards retain the vendored security and rendering provenance. The repository WASM gate checks the library layers. The separate web application shell also passed `cargo check --locked --target wasm32-unknown-unknown -p pdfcraft-web` in `egui037-pdfcraft-web-shell-check-20261025-0575`. Native GUI and IME/PRIMARY, browser interaction, packaged WASM size, real-world old settings and paired app performance remain open; these checks do not establish cross-platform acceptance. + +This section supersedes every historical statement below that the egui, winit or +craft-libs revisions resolve through `local-integration-config.toml`, that the +checked-in manifest does not pin them, or that E21b/P7b surrounding-text support is +absent from the candidate. Those statements are planning-era history; the current +tree does not use an external Cargo config. + +| Item | Current value | +|---|---| +| egui family | `6d518fc799681571963842f95b55a6b68431b15a` (storytold/egui) | +| winit | `a517b3846ee8457d84ee4975110bfda6797c44dc` (storytold/winit) | +| craft-ui / craft-quit / craft-print | `25da1314d443171b4adeaad2cd9454b273f35c6f` (storytold/craft-libs) | +| `Cargo.lock` SHA-256 | `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80` | +| Target-main observation | `a7801dc84e74dd12757aecbb6be324dce7a33939` | + +`Cargo.toml` pins those revisions directly and `Cargo.lock` resolves them to the +same immutable git sources; no `local-integration-config.toml` is read. The +retained `vendor/winit` and `vendor/egui-wgpu` copies remain provenance for the +patch text and source guards, not runtime path patches. + +The pinned egui `6d518fc` tree contains the `TextEdit` outgoing +surrounding-text/`IMEOutput` producer and egui-winit calls +`set_ime_surrounding_text`. PdfCraft adds no custom adapter. Native platform +execution and the outgoing-text tests remain unqualified. + +## 1. Baseline and scope + +| Item | Value | +|---|---| +| PdfCraft source | current owned checkout `a2ec27b4775f53c40cad3456936d9406611e3a8d`; the original planning baseline was `0dc77fe4ed8810b9e9f25b9dd050afae662c062a` (`docs/egui-037-plan-20261022`, tree `ebb7259286988cf937ade02ab08db67064b04084`) | +| PdfCraft lockfile | current `Cargo.lock` is the direct-pin qualification lock (SHA-256 `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`); it is not publication evidence. The planning baseline lock SHA-256 was `eaee6973f7c1ba79f5bfc1bfa3da50ef457d13affeb51a3d50d27437804d4cf7`. | +| Planning-baseline egui family | `egui`, `eframe`, `egui-winit`, `egui_extras`, `egui_kittest` 0.36.2 from crates.io; `egui-wgpu` 0.36.2 vendored | +| Planning-baseline winit | 0.30.13 vendored; upstream tag commit `e9809ef54b18499bb4f2cac945719ecc2a61061b` | +| Planning-baseline craft-libs pins | `craft-ui` `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-quit` `f58bc1a3df490992d633393d0898af0588ce882c`, `craft-print` `666c358065261a2816b376fa3bb10538dbf53040`, `craft-docpath` `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` | +| Planning-era shared-libs integration base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; an ancestor of the current craft-libs pin `25da1314d443171b4adeaad2cd9454b273f35c6f` | +| Historical planning observation of craft-libs PR 199 | OPEN / CONFLICTING at the then-inspected head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; API-port ancestor `8614e4a04fae516fba16c22b31ff1008cb6be10b`; audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` | +| Target egui | 0.37.0, upstream release commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | +| Planning-era reviewed egui candidate | `aiongg/craft/0.37` HEAD `a4f9a17f64a6bae61350fb85804c9df0d9865916`; the current pin is `6d518fc799681571963842f95b55a6b68431b15a` | +| Planning-era reviewed winit candidate | `craft/0.30.13-lang` HEAD `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; it lacks W15P PRIMARY, which the current pin `a517b3846ee8457d84ee4975110bfda6797c44dc` contains | +| Shared rollout contract | `craft-libs/docs/egui-037-suite-rollout.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared library plan | `craft-libs/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared egui plan | `egui/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | + +The following paragraph records the pre-merge planning assessment; PR 199 subsequently merged, and the current pinned shared-library candidate includes its integration and later main work. At planning time, PR 199 was an input to the shared-library work, not a branch to merge. Its head and `ec8840a` are stale relative to current main: they omit `craft-platform` and revert security, settings, docking and printing fixes. Only the API-port hunks from `8614e4a` and the README change from `1764bca` are reused, applied onto `3832c47`; the final library revision must be reviewed and published separately. + +This plan covers only PdfCraft. It does not adopt, reset or migrate any other checkout, job, VM or shared target. Historical diagnostics retained in the private task evidence for `egui037-audit-20261022` are pointers only; they are not acceptance evidence. + +Entry points: + +- Native: `apps/pdfcraft/src/main.rs::main` calls `eframe::run_native`; `app_creator` (`main.rs:365`) builds `PdfCraftApp` and wires storage, control, recovery and GPU state. +- Web: `apps/pdfcraft-web/src/main.rs::main` uses `eframe::WebRunner::start` with `WebOptions { renderer: Glow }`, `?file=`, `?host=parent` and the before-unload warning. +- Headless: `crates/ui-egui/examples/shot.rs` uses `egui_kittest::Harness::build_eframe` plus `Harness::render`; `apps/pdfcraft-cli` has no egui/eframe dependency. +- Tests: `crates/ui-egui/tests/it/main.rs` links the integrated UI tests; `apps/pdfcraft/tests/it/main.rs` links twelve winit source guards; `crates/ui-egui/src/control.rs` has the in-crate control/pointer probes; `crates/ui-egui/tests/perf.rs` is the existing timing harness. + +Targets to keep green: + +- `apps/pdfcraft` (native binary; wgpu plus glow fallback, accesskit, persistence, links, x11/wayland, native clipboard). +- `apps/pdfcraft-web` (wasm32, eframe glow; no egui-winit clipboard feature is needed or available there). +- `apps/pdfcraft-cli` (no egui dependency; unchanged). +- `crates/ui-egui` (library, unit tests, `tests/it` integrated binary, `tests/perf.rs`, `examples/shot.rs`). +- `xtask` gates (`fmt`, clippy, test, layers, wasm, assets, deny, parity). + +## 2. Actual lifecycle, features and compatibility surface + +### 2.1 App boundary + +`PdfCraftApp` is declared in `crates/ui-egui/src/lib.rs`; the eframe implementation is `impl eframe::App for PdfCraftApp`: + +- `raw_input_hook` applies line-scroll speed and injects the OS drag pointer position before egui reads the frame. +- `save` repairs minimized-window storage through `window_state::WindowState::keep`. +- `logic` owns host-side work: context capture, window repair, i18n selection, font installation, UI scale, drag/drop completion, inbox/open queues, OS events, control-channel requests, quit guard, autosave, file watching, update/print/export/OCR/optimize/action polls, deferred shortcuts, per-view input blocking, pending edits and picker completion. +- `ui` owns per-pass layout: title command, full-screen or normal panels, tab strip, mode bar, right rail, docking or read-mode central panel, palette, dialogs, progress, toasts, then `finish_render_frame`. +- `finish_render_frame` suspends hidden tabs' render pools and commits the visible/compare views' frame demand. + +`crates/ui-egui/src/canvas.rs` is a second lifecycle: `DocView::begin_render_frame` resets demand and the frame queue; `DocView::finish_render_frame` builds the render queue, feeds the pool and calls `DocView::receive` for a bounded upload batch. + +### 2.2 Lifecycle truth: `logic` is not a frame clock + +At the pinned 0.37 source, both native eframe (`crates/eframe/src/native/epi_integration.rs::update`) and web (`crates/eframe/src/web/app_runner.rs::logic`) call `app.logic` and then `app.ui` inside the `Context::run_ui` closure. A discarded layout reruns both. Hidden or occluded windows can call `App::logic` through `Context::run_logic`, which updates only raw viewport/window-focus information; events, input time, layout and normal pass state remain from the previous UI pass. Input therefore belongs only in a known UI phase (`App::ui` or an explicit method it calls), never in background work that reads `ctx.input` under the assumption that the window is hidden. + +Two corrections are binding: + +- `ViewportInfo::visible()` describes minimized/occluded state; it does not prove that the current callback is logic-only. A hidden UI paint can be requested for screenshots or injected input. +- After a normally completed `run_ui`, `current_pass_index()` is zero even after multiple passes: `ContextImpl::end_pass` takes the viewport output with `mem::take`, and `run_dyn` restores the count only immediately before the next UI pass. A first-pass guard does not by itself skip ordinary hidden `run_logic` calls. It is still not a frame clock and cannot supply fresh input. + +No `run_ui_with_logic`/E22 scheduler API is adopted. There is no stable one-frame boundary inside `run_ui`, and both hosts already run logic per pass. Queues keyed by consumed identity, command/generation identity and monotonic deadlines provide the required behavior. + +### 2.3 Hidden work and current input + +- `App::logic` may run repeatedly in one visible frame and may run alone while hidden. Autosave, recovery, playback/audio, open completion, file watching, polls and undo application stay there, but use monotonic elapsed time (`std::time::Instant`, `web_time::Instant` on wasm), not `ctx.input(|i| i.time)`. +- Fresh wheel, pointer, keyboard and IME input is handled in `App::ui` before panels. `Motion::begin_pass` moves from `logic` into `ui` (before any widget reads wheel input) or is guarded by a fresh-UI-pass token; it must never consume the same `ctx.input` event twice. +- The grid-drop deadline and control-adjacent timers use monotonic deadlines too. +- UI-produced commands may commit promptly from the UI. The requirement is exactly-once consumption, not "never commit on a discarded pass": egui cannot know at commit time whether a later widget will request a discard. Screenshot completion and render queues may be cancelled without side effects; an edit/undo transaction may not be duplicated when the pass is replayed. + +### 2.4 Feature and cfg inventory + +| cfg / feature | Where it matters | +|---|---| +| `target_arch = "wasm32"` | `apps/pdfcraft-web`; `ui-egui` disables `file_watch`, `pickers`, `system_fonts`; `fill_sign` image inbox and host bridge; no native window and no egui-winit clipboard | +| `windows` | hidden-maximized restore, DPI change, DWM frame, IME cursor, auto language, measurement units, DX12 presentation (`apps/pdfcraft/src/main.rs`, `motion.rs`, `single_instance.rs`, winit patches W1/W3/W4/W18/W19) | +| `target_os = "macos"` | Apple events, traffic lights/full-size content view, ⌘Q menu (`craft-quit`), title-bar drag, IME commit/discard/emoji/Korean (W6–W10, W12, W22–W24, W26) | +| `target_os = "linux"` | GNOME/Wayland frame decision, compositor GPU selection, Wayland regular clipboard and W15P PRIMARY (W15/W15P/E4), X11 DPI/IME (W5/W11/W20/W21), Wayland scroll phases (W13) | +| `unix, not macos` | single-instance Unix socket, Wayland/X11 detection, fontconfig directory parsing | +| `not unix` / other | Windows and wasm branches above; no silent fallback to a native-only API | +| Native eframe features | `default_fonts`, `wgpu`, `glow`, `accesskit`, `persistence`, `links`, `wayland`, `x11`, **`clipboard`** | +| `ui-egui` eframe features | `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`, **`clipboard`** for native consumer/test parity | +| Web eframe features | `default_fonts`, `glow`, `persistence`, `accesskit`; no winit clipboard path | +| egui features | `bytemuck` in `ui-egui`; `default_fonts` explicit; **`monochrome_emoji_fonts`** in every egui-consuming target that draws PdfCraft UI text | +| eframe `system_fonts` | stays off; `NativeOptions::system_font_fallback = false` | + +`cargo xtask wasm` checks crates below L8; `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` remains required for the actual web shell. A CI feature-identity assertion must show `egui-winit/clipboard` enabled for native roots after the migration, and `cargo deny`/`cargo metadata` must show exactly the approved fork sources. + +### 2.5 Control channel and screenshots + +`crates/ui-egui/src/control.rs` is the user-observable automation surface. Today it installs `ControlPlugin`, stores the AccessKit tree and injects queued input in hooks, handles requests in `Control::tick`, receives screenshots as `egui::Event::Screenshot`, sends `ViewportCommand::Screenshot(UserData::new(tag))`, crops and PNG-encodes in `screenshot_png`, and times out after 5 seconds. Its tests live both in `src/control.rs` cfg-test modules and `crates/ui-egui/tests/it/control.rs` (65 tests, linked by `tests/it/main.rs`), including discard handling and `screenshots_of_window_and_region`. PR 1 updates both sets. + +egui 0.37 removes `Event::Screenshot` and changes `ViewportCommand::Screenshot` to a one-shot `ScreenshotCallback`. A pending screenshot command itself asks the integration to paint a hidden window; `RequestPaintWhileHidden` is additionally required when injected input must be applied without a screenshot. + +`Shared::passes` is a pass counter, not a frame counter. The migration renames the unit to accepted frames and increments it in `output_hook` only when `output.platform_output.request_discard_reasons.is_empty()`; an intermediate discarded pass does not count. A request made before a forced discard is not answered before the intended number of accepted frames. + +### 2.6 Panels, docking and window chrome (planning baseline) + +PdfCraft uses the 0.37 `Panel` API already in its 0.36 form: + +- `chrome::tab_strip`: `Panel::top("tab_strip")`; +- `chrome::mode_bar`: `Panel::top("mode_bar")`; +- `chrome::right_rail`: `Panel::right("rail")` and `Panel::bottom("rail-nav")`; +- read-mode inspector: `Panel::right("read_right_panel")`; +- central panel in `lib.rs`. + +0.36 stores `PanelState` under the panel `Id` passed to the constructor. 0.37 derives it as `parent_ui.scope_id().with_salt(IdSalt::new(id_salt))`; the panel child `Ui` is also salted with the new panel ID. Every named panel key therefore changes, and in-panel widget/focus/scroll state may reset. This is not left to implementation: §6.2 migrates the persisted keys in the first compatibility PR and tests the upgrade from a 0.36 fixture. + +Docking is `crates/ui-egui/src/docking.rs`; `DockArea::new(egui::Id::unique("pdfcraft-docking"))` wraps `craft_ui::docking`. Persistence is PdfCraft's own `Workspace` JSON, not egui panel memory. `DockArea::direction` is a new opt-in stock-compatible API planned in craft-libs CL-3; the explicit RTL adoption in PR 4 waits for that reviewed revision. Its visual policy preserves saved physical splits, rectangles and logical tab order. + +### 2.7 Window frame, decorations and restoration + +`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. + +`craft-window` owns `Frame`, `Layout` and `Button`; `window_controls.rs` currently has distinct duplicate types and its own GNOME `button-layout` parser. The migration makes `window_controls` re-export `craft_window::{Button, Layout}` and removes the duplicate parser. `decorations::app_drawn` uses `craft_window::Choice::probe(Prefix::new("PDFCRAFT"))`; `PDFCRAFT_TITLEBAR` accepts `app`, `system` and `desktop` (the last two force the desktop frame), matching craft-window. PdfCraft still applies `with_decorations(false)` only when it will draw its own header bar, and the runtime “Use system title bar” preference still toggles `ViewportCommand::Decorations`; craft-window decides the startup frame, not the runtime preference. The probe is called once at startup and never from `App::ui`. + +### 2.8 Fonts and language + +`theme::install_fonts_for` calls `ctx.set_fonts(installed_font_definitions(cjk))`; `installed_font_definitions` appends one installed face plus optional script fallbacks. `theme::on_interface_baseline` computes `FontTweak::y_offset_factor` from primary and fallback metrics. `system_fonts::fallback` is the single-face, last-resort policy; `PDFCRAFT_SYSTEM_FONTS=0` disables it. + +At the planning baseline, the i18n layer preordered right-to-left translations at catalog load (`i18n::LangInfo.rtl`, `catalog.map_translations(display_rtl)`), and `bidi::display_rtl`, `bidi::visual`, `bidi::wrap` and `bidi::visual_auto` preordered runs because egui 0.36 does not. The current migration removed catalog preordering; `display_rtl` and the other preordering helpers remain test-only. The fork's E5/E12/E13/E15/E16/E17/E20 own reordering now. Keeping `display_rtl` in production alongside E5 would double-reorder text. + +### 2.9 Clipboard, IME and input + +Text copy/paste goes through egui events; images and file paste use `arboard` directly. Native eframe 0.37 no longer enables `egui-winit/clipboard` implicitly when `default-features = false`; without the explicit feature, text cut/copy/paste loses OS integration and the Wayland clipboard/PRIMARY path is compiled out. `arboard` image/file code can mask that loss in a superficial check. + +PdfCraft uses ordinary `egui::TextEdit` for its dialogs, forms, comments composer and text editor; it has no custom IME editor producer requiring an app-side surrounding-text adapter. The pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` surrounding-text path, and egui-winit calls `set_ime_surrounding_text`; native platform execution and outgoing-text tests remain unqualified. + +## 3. Dependency, lockfile and vendor audit + +### 3.1 Manifests + +| Manifest | egui/eframe facts and required change | +|---|---| +| `Cargo.toml` workspace | egui/eframe/egui_extras/egui_kittest 0.36; `[patch.crates-io]` contains `winit = vendor/winit` and `egui-wgpu = vendor/egui-wgpu`. Replace window-stack path patches with approved immutable fork revisions; retain PDF vendors | +| `crates/ui-egui/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`; add `clipboard`; egui features gain `monochrome_emoji_fonts`; egui_kittest dev-dep `eframe`, `wgpu` | +| `apps/pdfcraft/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11`, `persistence`, `links`; add `clipboard`; Linux dev-dep winit 0.30 for the scroll-policy test | +| `apps/pdfcraft-web/Cargo.toml` | eframe features `default_fonts`, `glow`, `persistence`, `accesskit`; no clipboard feature or egui-winit dependency | +| `apps/pdfcraft-cli/Cargo.toml` | no egui/eframe; unaffected | + +egui 0.37 no longer enables `default_fonts` by default. PdfCraft explicitly enables it in its own features. `monochrome_emoji_fonts` is chosen (not replacing `✔`/`▲`/`▼` with new SVG glyphs): the fonts are already OFL/MIT, attributed, and preserve existing text symbols deterministically with `PDFCRAFT_SYSTEM_FONTS=0`. The painted submenu arrow is not a font glyph after 0.37 and needs no emoji font. + +### 3.2 Lockfile duplicates and family resolution + +At planning time the lockfile contained one egui family at 0.36.2; the current qualification lockfile resolves the family at 0.37.0 (see `Current qualification state — 2026-10-25`). Several `vello_common`/`vello_cpu` minors still come through the PDF renderer and epaint. The target state must prove one egui family, one epaint family and one winit: + +- `egui`, `epaint`, `emath`, `ecolor`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_extras`, `egui_kittest` all at 0.37.0 from one source. +- `winit` at 0.30.13 with the craft language patches, from one source. +- `wgpu` remains 30.x; `egui-wgpu` matches the resolved wgpu. +- `accesskit` moves 0.24.x → 0.25.x; `accesskit_winit` 0.32.x → 0.34.x; epaint moves to `vello_cpu` 0.2.0 while hayro remains on 0.0.8. E11 is not required for PdfCraft because these versions cannot unify (verify with `cargo tree -e features -i vello_cpu`). +- Verify `cargo metadata` package versions and sources, not tag names. + +### 3.3 Vendored winit provenance and compatibility + +`vendor/winit` is 0.30.13 (`vendor/winit/.cargo_vcs_info.json` = `e9809ef5…`) and carries **W1–W13**, not W1–W15. It has no language APIs. The planning-era candidate winit fork `8b35388b` carried W1–W15 plus W17–W27 but not W15P; W16 is not in this cut. W14 (`c488d2e9`, macOS cancelable termination plus `dpi` from crates.io) and W15 (`cf73a29c`, `b05ce129`, per-seat Wayland data device and regular clipboard) are in the fork, not the current vendor. + +W14 remains opt-in in the shared winit revision and is **not enabled** by PdfCraft; its existing termination path stays `craft-quit`. Validate that `dpi` resolves once with the git source. W15 regular clipboard is required by E4. In the planning-era candidate `8b35388b`, the **W15P** PRIMARY implementation was a prerequisite added by the shared winit work, using SCTK 0.19.2's `PrimarySelectionManagerState`/device/source/offer on the existing connection and event queue, and had to land before egui P2b. The current pin `a517b384` contains W15P (`PATCHES.md` W15P row; `src/platform_impl/linux/wayland/clipboard.rs`) and W27; native evidence remains open. No second regular `wl_data_device` is acceptable. + +The egui fork requires companion winit APIs on their target platforms. Integrate and review the series in the approved public storytold fork, compare every current PdfCraft vendor delta, transfer behavioral tests, then replace the path patch with the final immutable git revision. Record provenance and old/new source hashes in migration evidence. Keep the original vendor until equivalence is established; do not publish an interim personal-fork branch dependency. + +### 3.4 Source-guard migration and exact inventory + +`apps/pdfcraft/tests/it/main.rs` links twelve winit guard modules. All twelve must be transferred to the winit fork (or replaced by equivalent fork-owned immutable tests), and only behavioral/ABI tests remain in PdfCraft: + +`winit_macos_emoji_picker`, `winit_macos_ime_commit`, `winit_macos_ime_discard`, `winit_macos_korean_first_key`, `winit_macos_korean_space`, `winit_macos_title_bar_drag` (W12), `winit_wayland_scroll` (W13), `winit_wayland_text_input` (W2), `winit_windows_hidden_maximized` (W4), `winit_windows_ime_cursor` (W3), `winit_x11_dpi` (W11), `winit_x11_ime_focus` (W5). + +PdfCraft keeps the retained `vendor/winit` and `vendor/egui-wgpu` copies as provenance and may read them only for provenance assertions (W1 patch text, base hashes, the W1–W13/E2+E3 rows), never as proof of the code the app runs. The resolved graph is checked from `Cargo.lock`: `vendor_graph.rs` requires the exact canonical storytold git URL, 40-character revision and fragment for the two crates it inspects, `winit` and `egui-wgpu`, and rejects path sources. W17 covers W2, W3, W5–W10, W12 and W13; add W1, W4, W11 and the egui-wgpu behavioral coverage to the same central evidence map. The W15/W15P guard belongs on the winit fork or a behavior test, never on `vendor/winit`. + +### 3.5 Vendored egui-wgpu provenance + +`vendor/egui-wgpu` is 0.36.2 (`49682f8b…`) with E2+E3. The target is 0.37.0 plus the same two patches. The reviewed fork's E2+E3 commit is `9dd799ab` and adds a `surface_fit` unit test; PdfCraft also has stronger guards: + +- `apps/pdfcraft/src/main.rs::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it` +- `apps/pdfcraft/src/main.rs::surface_fit_never_empties_or_overflows_a_surface` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_surface_fit_identity` +- `apps/pdfcraft/src/main.rs::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_catch_errors_identity` +- `apps/pdfcraft/src/main.rs::pipeline_creation_error_is_returned_and_device_remains_usable` + +Integrate E2+E3 and the behavioral regressions into the central 0.37 fork, then consume the approved revision. Replace the source-path/source-absence assertions with immutable package identity checks. `surface_texture_limits` and `configure_gpu` remain: the first raises the enabled 2D limit, the second preserves adapter selection, DX12 presentation and indirect-validation behavior. + +### 3.6 craft-libs: port only, and account for craft-print/GDI/rgb drift + +PR 199's egui port changes `craft-ui` roles, `Id::unique`/`Ui::scope_id`, `egui_extras`/`egui_kittest` 0.37, and the docking fixture's `⋮` → `…`. Reuse those API hunks on current main; do not import the stale tree. PdfCraft needs `craft-ui` for docking/icons/tabs/layout, `craft-window` for the frame decision, and no wholesale move of its PDF-specific print UI into `craft_ui::printing`. + +Bumping all four craft-libs crates from their four current pins listed in section 1 to a revision based on `3832c47` carries more than a new field: + +- `craft-print` adds the unconditional `craft-winprint-gdi` dependency (`storytold/winprint.rs`, revision `417ac3c4…`), a public `rgb` re-export of the GDI transport, `lpstat -l` description parsing and `Printer.description`. +- Audit `craft-winprint-gdi` licensing/target cfg/`cargo deny`/Windows build, and the non-Windows `rgb::PrintError::Unsupported` behavior. +- `Printer` now has `description`; update every literal in the repository. At baseline there are 19 `Printer { … }` literals across `crates/print` and UI tests, not 13. This is a mechanical compile fix, with the craft-print tests and Windows native checks re-run. +- Keep PdfCraft's own `print_ui.rs` sheet preview and options. `description` is not automatically displayed; a product change to show it is separate. +- The current main already includes intervening security bounds, settings merge, docking permissions and linear printer-name collision checks; preserve them. The shared-library plan's CL-1 diff gate remains mandatory. + +### 3.7 Publication and duplicate-resolution rules + +- Pin immutable revisions. A branch name is not a pin. +- `[patch.crates-io]` in the egui or winit repository does not propagate into PdfCraft. PdfCraft's root must patch every resolved egui-workspace package, including `egui_glow` for GL, `epaint_default_fonts`, the used core/render/test crates, and winit. +- Use approved public immutable storytold fork revisions after vendor equivalence review. Never add a machine-local absolute path patch or publish another vendor fork to bypass dependency publication. +- Add a CI assertion that `cargo metadata` resolves exactly one package ID for each family crate and one winit, and that no `path` dependency points outside the repository. + +### 3.8 Vendors outside the UI port + +`vendor/hayro*`, `vendor/lopdf` and `vendor/vello_common` are renderer/text-engine vendors. Keep their revisions, patches and tests. Inspect vello package identities and feature unification; incompatible versions may legitimately coexist. Do not force unrelated major versions to unify or edit PDF renderer vendors merely to make `cargo tree -d` empty. + +## 4. egui 0.37 breaking changes that touch PdfCraft + +| Change | PdfCraft use | Required action | +|---|---|---| +| `default_fonts` no longer a default feature | explicit in ui-egui, pdfcraft, web, craft-ui dev-dep | keep explicit | +| `monochrome_emoji_fonts` gates emoji faces | `✔`, `▲`, `▼` and previously `⏵` text | enable the feature; add `egui-icons.ttf` attribution; keep painted submenu arrows out of font coverage | +| eframe `clipboard` becomes explicit | native eframe with `default-features = false` | enable in native app and native UI tests; web needs none; add feature-identity check | +| `WidgetType` deprecated alias for `Role` | 52 references, 56 `widget_info` closures | map roles explicitly | +| `Id::new` deprecated → `Id::unique` | 74 references | semantic rename | +| `Ui::id()` deprecated → `Ui::scope_id()` | 20 references | mechanical rename; `scope_id()` is the value `id()` returned, not a new inherited ID | +| `UiBuilder::id` deprecated → `scope_id` | `canvas.rs:2607`; not `chrome.rs:697` | rename only the UiBuilder call | +| `Panel` constructors take `AsIdSalt`; panel keys derive from parent scope | 5 panels | migrate 0.36 `PanelState` keys in PR 1 and test the upgrade | +| `Ui::shrink_clip_rect` for panels | all panel content | test clip intersection and overlays | +| `ViewportCommand::Screenshot` callback; `Event::Screenshot` removed | `control.rs` | bounded weak-slot callback; screenshot command itself paints a hidden window | +| `Event::MouseWheel` gains `source: MouseWheelSource` | 41 references | add source; use `Unknown`/`Wheel`/`Trackpad`/`Momentum` only | +| `ScrollStyle.kinetic` / rubber-banding | `fling.rs`, `wheel_pager.rs`, `canvas.rs` | keep PdfCraft fling as document owner; leave egui kinetic for ordinary ScrollAreas; prove one curve | +| `NativeOptions.system_font_fallback` and `system_fonts` feature | not enabled today | set false and guard | +| kittest panics on missing glyphs and unnamed inputs | 204 harness constructions in `tests/it` | fix names/glyphs in PR 1; no global disabling | +| `Event::PasteImage`, `MiddleClickPaste`, PRIMARY | image paste via arboard | adopt the W15P PRIMARY contract; no app-level filtering of the event | +| `Response::accessible_name`, `Ui::label_inputs_by`, `Ui::name_inputs` | custom widgets and text fields | use for accessible names | +| Submenu arrow is a painted `arrow_atom` | test labels `"File ⏵"`, `"View ⏵"`, etc. | update labels/accessible names and keep an arrow-painted assertion | + +## 5. Fork patch audit: E1–E21 and E21b (planning-era baseline) + +Base: `0.37.0` = `841a328b`. The E1–E21 audit below was made against the planning-era fork head `a4f9a17f`; the current qualification pin is `6d518fc` (see `Current qualification state — 2026-10-25`), and the E21b row records the later pin. The patch table in the fork's `PATCHES.md` is provenance; each row was re-audited against the source at `a4f9a17f` and PdfCraft's needs, so rows naming `a4f9a17f` are planning-era history. + +| # | Fork commits | Crate | Upstream status at 0.37.0 | PdfCraft need | PdfCraft tests / removal condition | +|---|---|---|---|---|---| +| E1 | `8f3abf06` | eframe | not upstream; `ViewportHooks` absent | none today: PdfCraft opens one root viewport; `--new-window` is a separate process | compile only; keep for craft-tablet. Remove when upstream adds per-viewport hooks or craft-tablet no longer needs it | +| E2+E3 | `9dd799ab` | egui-wgpu | not upstream; #8361 open | required: `Surface::configure` panic and start-up errors | six `apps/pdfcraft/src/main.rs` surface/GPU guards plus fork-owned behavioral tests; remove when upstream fixes #8361 and the start-up error path | +| E4 | `66bacf23` | egui-winit | upstream #8657 draft; 0.37 still uses smithay | required on Hyprland and every Wayland compositor serving one data device | W15 regular clipboard plus W15P PRIMARY; native Wayland single-data-device test; remove when upstream has the behavior | +| E5 | `0e27d87e`, `777977b7`, `f7e89e23`, `5b2b8e19` | epaint, egui | #8577 open, #8698 reviewed; not in 0.37 | required for Arabic, Kurdish, Hebrew and mixed-direction text | bidi shaping, caret, selection, mark and no-default-fonts tests; remove only when upstream merges #8577 plus fixes | +| E6 | `0abfabcf` | egui | not proposed | required: caret moves must not kill IME composition | text-edit IME focus test; remove when upstream has equivalent | +| E7 | `0670c58a` | egui-winit | not upstream | required: shortcuts on Russian/Arabic/Hebrew Windows layouts | native Windows layout test plus source guard; remove when upstream | +| E8 | `1b03c10f` | egui | not upstream | required: Thai/Vietnamese/Arabic marks above and below the row | TextEdit pixel/ink test; remove when upstream | +| E9 | `b2f5cafc`, `e1341a3d`, `5105330d`, `75a5b3d3` | egui | not upstream | required for Arabic/Kurdish chrome (menus, scroll bars, combo boxes, headers) | RTL chrome geometry tests, no double-mirroring; remove when upstream | +| E10 | `dc90c13e`, `c8e573f7`, `34b2c0d4` | eframe web | not upstream; 0.37 has no `web_keys.rs` | required for the web build on non-Latin layouts and named keys | web build + browser keyboard test; remove when upstream | +| E11 | `92e61afd` | epaint | #8641 draft; not in 0.37 | not required by PdfCraft: epaint 0.2 vello_cpu cannot unify with hayro 0.0.8 | verify with `cargo tree -e features -i vello_cpu`; keep because other suite apps enable multithreading; remove when upstream flushes | +| E12 | `e955bb18`, `485c0b89`, `0185887c`, `7015ac23`, `4067b2c4`, `620e070e` | epaint, egui | not upstream; follow-up to #8577 | required for paragraph direction, RTL alignment, ellipsis at the visual end, UAX #9 L1 and linear reordering | direct direction/ellipsis/L1 tests; remove when upstream | +| E13 | `5e4da7f4` | epaint, egui | not upstream | required for caret/hit-test/Delete/Backspace by grapheme cluster | emoji, combining accent, Devanagari cluster tests; remove when upstream | +| E14 | `cbd77549`, `b3722ac0` | epaint, egui | not upstream | required: fallback faces on the primary baseline and rows growing to fit | replace PdfCraft's manual `y_offset_factor`; baseline/row-height tests; remove when upstream | +| E15 | `f1cae609` | epaint, egui | not upstream | required: `LineBreaker` trait, grapheme-safe emergency breaks, `LayoutJob::language` | CJK/Thai/Hindi line breaking tests; remove when upstream | +| E16 | `b3ef9771`, `bed6dfe3` | epaint, egui | not upstream | required: visual cursor movement with affinity, Home/End in reading order, password mask per cluster, neutral paragraph direction | bidi cursor tests and password-mask test; remove when upstream | +| E17 | `520435df` | egui | not upstream | required: `Context::set_language`, `UiBuilder::language`, AccessKit root language, one text run per bidi level | AccessKit language and text-run tests; remove when upstream | +| E18 | `89de8f3c` | eframe | fork-only | required as the bridge from PdfCraft's language to E17 and web screen reader/IME `lang` | `Frame::set_ui_language` test; remove when upstream | +| E19 | `d64047bf`, `642b74f7`, `baeb4515` | egui-winit, egui | fork-only until winit has the APIs | required for Latin shortcut letters on non-Latin layouts | needs W23/W25; app shortcut test; remove when upstream | +| E20 | `23955535` | egui | fork-only | required so widget text uses the `Ui` language for shaping and line breaking | language/line-break test; remove when upstream | +| E21 | `a70470b0` | egui-winit, egui | not proposed | required for macOS press-and-hold/dictation and Wayland IME replacing exact bytes | needs W26/W27; byte-count IME test; remove when upstream | +| E21b/P7b | `6d518fc799681571963842f95b55a6b68431b15a` | egui-winit, egui | present in the pinned fork; native execution unqualified | bounded outgoing surrounding-text payload, TextEdit producer and Wayland W27 adapter are present in the pinned tree | logical/byte-boundary/password/focus/over-limit tests; native Wayland trace remains open | + +### 5.1 Audit notes and traps + +- **E2+E3 are not one patch.** The surface fit and the error scopes protect different failures. +- **E4 + W15P.** Regular clipboard uses the winit data device (W15). PRIMARY is a distinct protocol object on the same connection/event queue and per-seat lifecycle (W15P), using SCTK's PRIMARY objects. It must not bind a second regular `wl_data_device`. Preserve password exclusion and no unchanged-selection reclaim. No “filter `MiddleClickPaste`” substitute is acceptable. +- **E21b is present in the pinned tree.** The planning-era candidate lacked the producer; pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` path and egui-winit calls `set_ime_surrounding_text`. PdfCraft uses ordinary TextEdit, so it needs no custom model adapter; native platform execution and outgoing-text tests remain open. +- **E5/E12/E13/E16 are a dependency chain.** Do not cherry-pick E12 without E5 or E13/E16 without E5. +- **E14 supersedes PdfCraft's baseline hack but not its Windows hinting.** Keep JetBrains Mono `HintingTarget::Smooth`. +- **E15/E20 change line breaking.** Native wrapping must be the only wrapper once active. +- **E17/E20 do not automatically force base direction.** PdfCraft sets `TextDirection::RightToLeft` where the UI locale requires it. +- **E19 is useless without W23/W25; E21 is useless without W26/W27.** +- **E11 is not required by PdfCraft.** + +### 5.2 winit patch provenance and compatibility + +Base: `v0.30.13` = `e9809ef54b18499bb4f2cac945719ecc2a61061b`. + +The retained vendor carries W1–W13. The planning-era reviewed language branch `8b35388b` carried W1–W15 plus W17–W27 but not W15P. W16 is not in this cut. The current pin `a517b384` contains W15P PRIMARY and W27 (`PATCHES.md`); native evidence remains open. + +| Group | Patches | Purpose | Upstream / removal | +|---|---|---|---| +| Windows DPI/frame/IME | W1 `b357a332`, W3 `fd104a4c`, W4 `3fffb7ce`, W18 `894bf2d7`, W19 `fa2e1b3d` | mixed-DPI suggested rect, Korean IME cursor, hidden maximized restore, hidden system caret, IME-consumed key presses | W1 merged on master; W3/W4 open; W18/W19 to be proposed | +| Wayland | W2 `13ca693b`, W13 `5506b1a9`, W15 `cf73a29c`+`b05ce129`, W15P (present in the pinned fork), W27 `f40c76a2` | text input owned by the seat, scroll phase policy, per-seat data device and regular clipboard, PRIMARY on the same connection, text-input-v3 incoming delete plus the outgoing surrounding-text API that E21b sends | W2 merged; W13 to be proposed; W15 #4729 open; W15P present in the pinned fork; W27 has master equivalents | +| X11 | W5 `283370ce`, W11 `4df8554b`, W20 `019c95f1`, W21 `23bb19a7` | focused replacement IME context, server DPI fallback, one input context per window, quiet IME spot | W5 open; W11/W20/W21 to be proposed; W20 supersedes W5 | +| macOS | W6 `a82b90fb`, W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W12 `7facf001`, W14 `c488d2e9`, W22 `0816871e`, W23 `c8cd8aa1`, W24 `4b5bc5a0`, W26 `2b9cd1cf` | NSTextInputClient commit flow, discard marked text, emoji picker, Korean space/first key, title-bar drag, cancelable termination, IME ranges, ⌘-layout names, reset, byte-count delete | W14 is opt-in and not enabled by PdfCraft; others open/to be proposed | +| Language/tests | W17 `2be27382`, W25 `eb4007da` | regression source guards; `platform::latin_layout::KeyEventExtLatinLayout` | tests go with their patches; W25 required by E19 | + +Compatibility rules: + +- The egui fork's `egui-winit` calls `winit::platform::latin_layout::KeyEventExtLatinLayout`, `KeyEventExtMacOS::key_with_command`, `WindowExtMacOS::take_ime_delete_surrounding` and `WindowExtWayland::take_ime_delete_surrounding`; E21b also calls `WindowExtWayland::set_ime_surrounding_text`. +- W14 remains compiled in the shared fork but not opted into by PdfCraft; `craft-quit` remains its terminate path. +- Re-run winit policy/unit tests in the selected fork and app-facing regressions. Native IME/compositor acceptance still requires actual workflows. + +## 6. Migration design + +### 6.1 Frame boundary and exactly-once side effects + +Keep the existing `eframe::App` hooks. Adding a private phase type is allowed only if implementation proves it simplifies real state ownership; do not add a scheduler API or rerun the whole app through a blanket `Context` bridge. + +1. `logic` remains the owner of queues, autosave, recovery, file watching, open completion, background polls, control request admission, undo application and other host state. Replayed passes must not re-enqueue an action, duplicate a command or add a second undo step. +2. UI-produced editing commands may apply in the UI, as they do today (`chrome.rs`, `canvas.rs`, `dialogs.rs` call `execute`/`apply_edit`). Preserve promptness. The rule is exactly-once by command/response ID or document generation, not “zero commits on discard”. A discarded pass may repeat layout; it must not repeat the edit. +3. Inventory every UI-side `execute`/`apply_edit`/undo path and add a forced-late-discard test using a real toolbar action. A per-pass pending-command queue keyed by `Response::id`/command identity is preferred over moving all UI side effects into logic. +4. `DocView::begin_render_frame` may collect per-pass demand. Render/upload commits use document/render generation, not `!ctx.will_discard()` as the correctness boundary. A late discard may discard demand work but must not lose an already-produced first-pass texture. +5. `Control::tick` remains in `logic` for queue servicing. It sends `ViewportCommand::RequestPaintWhileHidden` while a screenshot or injected input waits. Injected input is consumed once; a replay does not replay the click. +6. Autosave, recovery, grid-drop timeouts and playback use monotonic time. `Motion::begin_pass` is called from `ui` before panels; no logic-only call consumes `ctx.input` or smooth-scroll state. +7. `Frame::set_ui_language` runs after language resolution, once per pass only when the resolved language changes; it does not read stale input from `logic`. +8. Never infer a logic-only phase from `visible() == Some(false)`; hidden UI paint is a known UI phase and must handle current input. + +### 6.2 Panels, docking and persistence + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +The first compatibility PR implements an actual one-time panel-key migration. For each of `"tab_strip"`, `"mode_bar"`, `"rail"`, `"rail-nav"` and `"read_right_panel"`: + +- old key (0.36): `Id::new(salt)`; +- new key (0.37 root panel): `ctx.viewport_id().root_ui_id().with_salt(IdSalt::new(salt))` (equivalently `parent_ui.scope_id().with_salt(IdSalt::new(salt))` for the root). +- Before the first 0.37 UI pass, if the new key has no persisted `PanelState` and the old key does, read the public `PanelState` and insert it under the new key. Keep the old key untouched for rollback. Do not reset a width that can be migrated. + +Test with a synthetic 0.36 memory fixture: write the old key with a known outer width, run the 0.37 app, and assert the panel width survives within 1 point. Dump the old/new IDs for one panel and one child widget. A one-time reset of transient in-panel focus/scroll state is explicitly accepted only if the upgrade test demonstrates it; persisted widths are not reset. PdfCraft's own `docking::Workspace` layout is not touched. + +Further tests: + +- Panel order: tab strip → mode bar → right rail → central panel. Read-mode inspector must not reorder the rail or duplicate a panel ID. +- A panel's content clip is a subset of the panel rect and parent clip; popups/comment overlays use `Area`/`Window` layers. +- Floating docking, tab detach, group move and split resize survive restart from `Workspace` JSON, not egui memory. +- `DockArea::new(Id::unique("pdfcraft-docking"))`; keep the same salt for craft-ui state. If RTL is selected, pass the explicit direction; persisted split/rectangles/logical tab order stay unchanged. + +### 6.3 Viewports + +PdfCraft has no deferred or immediate child viewports. Keep `ViewportId::ROOT` as the only production viewport. `window_state` and tests already use it. E1 `ViewportHooks` remains a suite feature, not a PdfCraft requirement. Do not add popout document windows in this port. + +### 6.4 Screenshot callback correlation + +Replace `ViewportCommand::Screenshot(UserData)` plus `Event::Screenshot` with a bounded, cancelable request record: + +- one completion slot per admitted request, owned by that request; the callback holds only a weak reference to the slot; +- callback stores at most one `Arc` and wakes the owning root viewport after releasing the slot lock; it never encodes PNG, writes a file, runs commands, or strongly captures `Context` in a cycle; +- `Context::request_screenshot` or `ViewportCommand::Screenshot` with the one-shot callback; `finish_pending` takes each completed slot exactly once and replies on the app thread; +- cancellation, timeout and viewport close drop/cancel the slot so a late callback cannot produce a file or second reply; +- admission defaults are 4 requests, 128 MiB/image and 256 MiB aggregate reserved + in-flight/completed image bytes. These cap pending requests and aggregate image bytes. Reserve checked bytes before issuing a capture, using the allowed surface dimensions; revalidate dimensions before issuance after resize/DPI changes. Release reservations on completion, cancellation, oversize/stale rejection and shutdown; tests assert retained bytes return to zero; +- store issuance-time target-UI logical viewport rect, `pixels_per_point`, requested region + and geometry epoch in the slot. Reject/retry any resize/DPI/zoom/surface epoch change + before save, even a change back. Callback arguments contain pixels only. The callback image may be smaller because E2 surface fitting reduced it; derive x/y image scale from the returned dimensions and recorded logical bounds, verify aspect/rounding, and do not use `ctx.pixels_per_point()` at completion. A resize/DPI generation mismatch retries within the deadline or returns a defined error; +- PR 1 waits for the approved craft-ui CL-2F completion-slot helper and adopts it directly; no temporary app-local implementation. Preserve PdfCraft request/path/region/reply policy in `control.rs`. + +Hidden-window tests are separate: + +- a hidden window with queued injected input needs `RequestPaintWhileHidden` to apply it; without the command the input times out; +- a pending screenshot command itself requests a paint. A request still queued in app + logic, or awaiting settling before UI issuance, needs `RequestPaintWhileHidden` to reach + that point; test admission and already-issued capture separately; +- keep late-callback, cancellation, two-region/out-of-order, close, admission overflow and DPI-change tests. Assert exact reply counts, dimensions/pixels, bounded memory and eventual queue drain. + +The dependency boundary also requires P2b's bounded asynchronous W15/W15P transfer API +(total deadline, cumulative byte cap, bounded workers/queue, completion wake and cancellation), +and P7's emitted-key identity paired by physical key for repeat/release. PdfCraft adds slow/trickle, +oversized/canceled paste and event-loop responsiveness checks, plus ordinary held-key/Ctrl+Z +layout-switch, release-modifier-first and focus-loss cases. Do not copy those internals locally; +consume the reviewed revisions and assert app-visible behavior. Clipboard shortcut keydown may +be intercepted before egui sees a Key event, so Ctrl+C/Cmd+V alone is not a stuck-key test. + +### 6.5 Fonts and glyph coverage + +Keep PdfCraft's single-face, last-resort system-font policy. Do not enable eframe's `system_fonts` provider silently. Set `NativeOptions.system_font_fallback = false` and keep a source/feature guard that `eframe/system_fonts` stays off. + +Glyph decision: enable `egui/monochrome_emoji_fonts` in `pdfcraft-ui-egui`, the native app and the web dependency graph. This keeps the existing `✔`, `▲` and `▼` text symbols and the OFL/MIT attributed Noto Emoji/emoji-icon fonts deterministic with `PDFCRAFT_SYSTEM_FONTS=0`; do not replace them with new SVG assets in this migration. The new painted submenu arrow is an atom, not text, and is removed from glyph expectations. + +Attribution and assets in the first green PR: + +- treat the 0.37 font change as a deliberate fixture change: `⋮` (U+22EE) was never covered by the bundled faces, and the craft-libs test fixture moves to `…`; do not restore a tofu-drawing fixture. Production `…`/`−` remain covered; +- update all four `[[bundled]]` `epaint_default_fonts` entries from 0.36.2 to 0.37.0; +- add `egui-icons.ttf`, SHA-256 `122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8`, recorded as the MIT-licensed `emoji-icon-font` subset by John Slegers, with its exact usage; +- run `cargo xtask assets --write` and then `cargo xtask assets`. + +Adopt E14 for fallbacks: set `FontTweak::fallback_align = FallbackAlign::Baseline` for script fallbacks; remove metric-derived `y_offset_factor`; keep Windows JetBrains Mono hinting. Add a glyph-coverage test for every UI literal and catalog translation with `MissingGlyphPolicy::Panic` (or `Fonts::has_glyphs`) for embedded-only, craft-fonts, each script fallback, and `PDFCRAFT_SYSTEM_FONTS=0/1`. A missing glyph is a failure, not a snapshot update. + +### 6.6 Language and RTL + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +End state: + +- `logic` resolves the language and calls `frame.set_ui_language(Some(code))` (E18), setting `Context::language` (E17). +- Translated UI text stays logical in catalogs. Remove `catalog.map_translations(display_rtl)`, `bidi::display_rtl` and the matching label preordering. Rely on E5/E12. +- Use explicit `TextDirection::RightToLeft` for `ar`/`ckb` labels whose first strong character may be Latin or a placeholder; use `LeftToRight` for file/path labels where the extension must stay at the end; use `Auto` for document-authored text. A small local direction helper is used only where multiple call sites need it; no new fork-only API. +- Replace `bidi::wrap`/`widgets::wrapped_text` with native `Label`/`LayoutJob` wrapping once E15 is active. A remaining custom wrapper must measure with the same direction/language it paints. +- Set `Style::right_to_left` for `ar`/`ckb` after auditing manual `Layout::right_to_left` call sites. E9 mirrors chrome, not every manually positioned control. Pass an explicit direction to `craft_ui::DockArea`; do not reverse saved workspace order. +- Keep `bidi::visual` only for painter paths that cannot use a `LayoutJob`; never combine it with native bidi on the same text. +- AccessKit labels and copied text stay logical; only painting is reordered. + +### 6.7 Input, IME and clipboard + +- Add `source` to every `Event::MouseWheel` constructor. The only variants are `Unknown`, `Wheel`, `Trackpad`, `Momentum`. On Wayland, Windows, X11 and web, winit reports `Unknown`; macOS reports `Wheel`, `Trackpad` or `Momentum`. +- PdfCraft's `fling.rs` remains the document-motion owner. On non-macOS platforms it keeps the existing `MouseWheelUnit::Point` + `TouchPhase::End` heuristic, explicitly including `Unknown`; never use the invented `Finger` or `WheelTilt` egui variants. On macOS its custom fling stays off because the OS sends `Momentum`; test that egui kinetic does not add a second document glide. Ordinary widget `ScrollArea`s keep the egui kinetic default; wheel deltas do not create coast velocity, only pointer-drag velocity and momentum do. +- E10/E19/W23/W25: verify shortcuts on non-Latin layouts through synthetic `RawInput` and native Windows/macOS/Linux tests. +- E21/W26/W27: test incoming `DeleteSurroundingBytes` and outgoing E21b surrounding text after delete/commit, deletion-only batches, preedit exclusion, reversed selection, UTF-8 boundaries, long/over-limit selections, field/window switching, password clearing and late serials. +- Text copy/paste stays on egui events; keep `arboard` for image/file clipboard. Enable eframe `clipboard` for native builds. PRIMARY uses W15P: keyboard-only focus, serial freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers, password exclusion and no unchanged-selection reclaim. Native evidence on wlroots/KWin/Mutter. No second regular data device. + +### 6.8 Accessibility + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +- Map every `WidgetInfo` role explicitly (`Role::TextInput`, `Role::CheckBox`, etc.). +- Add accessible names to every input widget. 0.37 kittest fails unnamed inputs; use `labelled_by`, `Ui::label_inputs_by`, `Ui::name_inputs` or `Response::accessible_name`. +- Keep logical labels in AccessKit. Do not set preordered visual strings. +- `ControlPlugin` copies the AccessKit tree; `ui.inspect` must keep stable widget IDs across panel migration. Test roles/labels for text field, slider, tab and custom button. +- Panel resize handles expose `Role::Splitter`. + +### 6.9 Rendering and GPU + +- Keep `surface_texture_limits` and `configure_gpu`. E2+E3 remain required. +- Do **not** skip `DocView::receive` uploads on `will_discard`. A first-pass texture is carried into the final merged `TexturesDelta`; skipping it can lose a page. Use generation identity to avoid re-scheduling renders and avoid duplicate whole-image deltas. +- Assert the final merged `FullOutput.textures_delta.set` contains one whole delta per texture generation; a forced-late-discard test must show the page after the next accepted pass. +- Keep `RESULTS_PER_FRAME`/`UPLOAD_BYTES_PER_FRAME`, hidden-tab suspension, `PDFCRAFT_RENDERER=wgpu|gl`, OpenGL retry and software-renderer notice. +- Screenshot readback is callback-driven; never assume it is ready in the same input frame. + +### 6.10 Commands, undo and persistence + +No command ID, tool ID, `parity/` entry, undo transaction or saved-settings key changes in this port. Preserve `commands.rs` registry IDs, `Session` document generations and undo steps, `Workspace` JSON, `window_state` storage key `window`, and `persist()` keys `pdfcraft`/legacy `printcraft`. The only persistence migration is egui's panel `PanelState` keys in §6.2. + +## 7. PR stages and rollback + +All publication is gated by explicit human approval. No push or PR is authorized by this plan. + +### Stage 0 — harness and baseline (before production change) + +- Land only this document and a test-only measurement driver/harness; no production behavior changes. +- The harness must run on the unmodified baseline `a2ec27b` and, where its adapter permits, stock 0.36.2, stock 0.37.0 and the fork. +- Record baseline frame/idle/RSS samples, `Context::tessellate` with 120 ASCII labels (#8731), startup phase split (instance/adapter/device → pipeline creation → first frame; #8736), and font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider on/off (#8512). +- Record `cargo metadata`, lock hash, vendor hashes, hardware, renderer/driver, DPI, fixtures and actual commands. A baseline may not be measured after production changes. + +### Shared fork prerequisites (external to PdfCraft) + +The implementation is blocked until the shared plans publish approved immutable revisions containing: + +- the reviewed winit W1–W15, W17–W27 and **W15P** PRIMARY path (all present in the current pin `a517b384`); +- the reviewed egui E1–E21 and **E21b/P7b** outgoing surrounding-text integration; +- the craft-libs API-port hunks on current main, with `craft-window` and `craft-print` drift reviewed. + +PdfCraft does not vendor a private copy or bypass missing publication with a path patch. + +### PR 1 — atomic first-green compatibility change + +Depends on: Stage 0 and the complete approved egui/winit revisions (P2b bounded W15/W15P, P7 key release pairing, P7b IME lifecycle, P7c selection fix), plus craft-libs CL-1/CL-2F. PR 4 RTL docking additionally waits for CL-3. + +Scope (one reviewable change set; commits may split by file only if every commit is green): + +- Pin the egui 0.37 family and winit 0.30.13 fork revision; update `Cargo.lock`. +- Enable native eframe `clipboard` in `apps/pdfcraft` and `crates/ui-egui`; add the feature-identity assertion. State that web has no egui-winit clipboard. +- Enable `monochrome_emoji_fonts`; update `ATTRIBUTION.toml` bundled versions and add `egui-icons.ttf`; run assets write/check. +- Add storytold egui/winit git URLs to `deny.toml` `allow-git`; keep `unknown-git = "deny"`. +- Rename `WidgetType`/`Id::new`/`Ui::id`/`UiBuilder::id`; map roles; fix all kittest accessible-name and missing-glyph failures (204 harnesses). +- Update painted submenu labels: `"File ⏵"` → `"File"`, etc.; update AccessKit/name expectations across `commands.rs`, `theme.rs`, `editing.rs`, `control.rs`, `text_select.rs`, `view.rs`; assert the arrow is painted. +- Add `MouseWheel.source` with the real variants; migrate the screenshot path to the bounded callback; add panel-key migration. +- Port from craft-libs current main using only the reviewed API hunks and `craft-window`; add `craft-window` type reuse and retain `PDFCRAFT_TITLEBAR`/runtime preference semantics. Add `Printer.description` to every literal (19 at baseline) and audit craft-print/GDI/rgb drift. +- Transfer winit/egui-wgpu source guards centrally; replace PdfCraft source-path and `source =` assertions with immutable git graph checks. +- Remove catalog preordering and `display_rtl` as the fork becomes the sole UI bidi owner; + include the focused logical-text/selection regression in this first fork pin. +- Do not defer names, glyphs, attribution, required bidi adaptation or `deny` to PR 3. + +Tests: fmt, clippy `-D warnings`, `cargo test -p pdfcraft-ui-egui`, `cargo test -p pdfcraft`, wasm check, `cargo xtask layers/assets/deny/parity`, native feature/graph assertions, source-guard replacements. Snapshot changes must be reviewed, never auto-accepted. + +Rollback: revert PR 1; keep the 0.36 lock and vendor directories in history. Do not leave a 0.37 manifest with a 0.36 lock. + +### PR 2 — lifecycle, control and render commit + +Depends on: PR 1. + +Scope: + +- Replace `ctx.input` clocks in autosave/recovery/grid/control timers with monotonic deadlines. +- Move `motion.begin_pass` into UI before panels; remove stale event reads from logic-only iterations. +- Complete exactly-once command/edit/undo handling with a real forced-late-discard test; remove any “no commit on discard” assumption. +- Finish screenshot slot stress: hidden injected input, screenshot command paint, cancel/late/out-of-order/DPI, aggregate reservations. +- Rename `Pending::Frames`/`Shared::passes` to accepted completed frames; increment only when `request_discard_reasons` is empty; add a forced-discard timing test. +- Add generation-safe texture tests; do not gate uploads on `will_discard`. + +Rollback: revert PR 2; PR 1 retains a complete 0.37 callback and basic regression coverage. + +### PR 3 — language, fonts, RTL and accessibility completion + +Depends on: PR 2. + +Scope: + +- `Frame::set_ui_language`; AccessKit language. +- Expand native wrapping and explicit `TextDirection` tests; catalog preordering and + `display_rtl` removal already landed in PR 1 and must not be restored independently. +- E14 fallback alignment; glyph coverage; `system_font_fallback = false`. +- `Style::right_to_left` after manual-layout audit; craft-ui `DockArea::direction`. +- Role/name tests already required by PR 1 are strengthened, not introduced here. + +Rollback: revert PR 3; PR 2 remains. If native bidi proves wrong, revert the whole 0.37 family to the 0.36 fork tag `767b1cad` with the old preordering. + +### PR 4 — platform window, packaging and documentation + +Depends on: PR 1 (can run in parallel with PR 2/3 after rebase). + +Scope: + +- The type re-export/parser replacement lands in PR 1; PR 4 completes extended native policy tests for `craft-window`: `window_controls` re-exports `craft_window::{Button, Layout}`; `decorations` uses `Choice::probe(Prefix::new("PDFCRAFT"))`; remove duplicate parser; keep runtime `Use system title bar` toggle. +- Native Linux/Windows GUI checks, web build, packaging checks and clipboard/PRIMARY/IME workflows. +- Update `vendor/README.md`/`SOURCE.json` only for retained vendors; document removed window-stack copies and exact replacement revisions. +- Update `docs/development.md` and version statements. + +Rollback: revert PR 4; PR 1–3 remain. + +### PR 5 — performance evidence and final gates + +Depends on: all. + +Scope: run the Stage 0 harness on the candidate; add paired release results, stock-0.37 control, peak RSS, idle CPU/repaint, texture counters and startup/font/tessellation measurements; update `ROADMAP.md`/status docs. No behavior change. + +DAG: + +``` +Shared forks published -> Stage 0 -> PR1 -> PR2 -> PR3 -> PR5 + \-> PR4 -> PR5 +``` + +PR4 may be rebased alongside PR2/PR3, but do not land two PRs that both rewrite `lib.rs` without a rebase review. + +## 8. Test plan with user-observable assertions + +### 8.1 Existing suites to keep green + +- `crates/ui-egui/tests/it` (one binary): accessibility, actions, panels, controls, comments, content, editing, export, fonts, forms, links, pickers, print, recovery, signing, text selection, viewer, XFA. +- `crates/ui-egui/tests/perf.rs`: collapsed comments, 500-page scrolling and panel budget tests. +- `apps/pdfcraft/tests/it`: twelve winit guards (transferred/replaced), and the Wayland scroll-policy compile test. +- `apps/pdfcraft/src/main.rs`: GPU/surface/adapter guards. +- `examples/shot.rs`: headless screenshot of the real shell. +- `crates/ui-egui/src/control.rs`: in-crate control, pointer and multi-pass probes. + +Include `crates/ui-egui/tests/it/control.rs`, its discarded-pass requests and `screenshots_of_window_and_region` in PR 1 migration and the existing-suite gate. + +### 8.2 New or strengthened assertions + +| Area | Test | Assertion | +|---|---|---| +| Lifecycle | `logic_runs_per_pass_but_one_action_commits_once` | Native/web-shaped driver runs logic + UI twice in one frame; one click yields one edit/undo step | +| Hidden logic | `hidden_logic_advances_monotonic_deadlines` | Repeated `run_logic` with advancing wall clock and no new events autosaves once and changes no scroll/zoom | +| Hidden input | `hidden_injected_input_needs_a_ui_pass` | Queued click times out without `RequestPaintWhileHidden`; applies exactly once with it | +| E22 | `completed_multipass_resets_pass_index` | After completed `run_ui`, `current_pass_index()` is zero; a hidden call still runs; no first-pass guard is used as a scheduler | +| Panels | `upgrade_from_036_panel_memory` | Old `Id::new(salt)` width migrates to root-scope key; width within 1 pt; ID dump recorded | +| Panels | `panel_widget_id_stays_stable` | A widget in a panel keeps the same `scope_id`-derived interaction ID across consecutive 0.37 frames; `unique_id` is not substituted for state | +| Panels | `panel_clip_is_intersected_with_parent` | Child clip no larger than panel/parent; overlay uses `Area` and remains visible | +| Docking | `dock_float_detach_and_split_survive_restart` | Same `Workspace` JSON round-trips; panel bodies once; Canvas cannot float/share tabs | +| Screenshots | `two_screenshot_requests_keep_their_slots` | Two regions complete out of order with own dimensions/colours; each reply once | +| Screenshots | `hidden_screenshot_command_paints_itself` | A pending screenshot paints a hidden window; no false `RequestPaintWhileHidden` requirement | +| Screenshots | `late_screenshot_is_dropped_after_cancel` | Timeout/close/cancel produces no second reply/file; reservations return to zero | +| Screenshots | `dpi_change_uses_capture_geometry` | Request-time ppp/rect correlate the image; mismatch retries or errors, never crops with new scale | +| Multi-pass | `discard_does_not_duplicate_edit_or_undo` | One toolbar action with `max_passes=2`: one edit, one undo step, one generation change | +| Texture | `discard_keeps_first_pass_texture_and_deduplicates` | Final merged delta has one whole image per generation; page visible next accepted pass; no upload skip | +| Control | `after_frames_counts_accepted_frames` | Forced discard does not answer early; answers after intended accepted frames | +| Input | `unknown_wheel_source_keeps_wayland_fling` | `Unknown` + Point + End glides on Linux; `Trackpad`/`Momentum` policy covered; no `Finger`/`WheelTilt` | +| Input | `one_finger_lift_glides_once` | Offset after 0.5 s follows `fling.rs`; egui kinetic adds no second document curve | +| Fonts | `every_ui_string_has_all_glyphs` | All `tl!` literals and catalogs pass `MissingGlyphPolicy::Panic` for embedded + craft-fonts + system-font-off | +| Fonts | `fallback_rows_share_the_baseline` | Fallback baseline equals primary; row grows; `y_offset_factor` not the mechanism | +| Language | `arabic_ui_labels_use_an_explicit_paragraph_direction` | With an Arabic face, the label's paragraph direction is `RightToLeft` and its Latin run sits right of the Arabic; English stays `Auto` | +| Language | `arabic_catalog_stays_logical` | Catalog and command lookups stay in logical order and placeholders keep their positions | +| Language | `a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name` | AccessKit label is logical; paint is visual only | +| Editing | `grapheme_caret_and_delete_are_cluster_safe` | Emoji ZWJ/combining accent move/delete as one cluster | +| IME | `delete_surrounding_bytes_replaces_exactly` | Exact reported bytes deleted; one undo step | +| IME | `outgoing_surrounding_text_is_fresh` | After delete+commit, preedit exclusion, cursor/selection changes, long/over-limit/password/focus cases; E21b payload present and bounded | +| Accessibility | `all_input_widgets_have_names` | Every text input role has non-empty label; no global check disable | +| Accessibility | `panel_tree_roles_and_order` | Tab strip, mode bar, rail and inspector once; expected roles/order | +| Menu | `submenu_arrow_is_painted_not_named` | `"File"` etc. accessible labels; arrow atom visible; no `"File ⏵"` dependency | +| Clipboard | `native_egui_winit_clipboard_is_enabled` | Native graph enables `egui-winit/clipboard`; web does not require it | +| Clipboard | `wayland_text_clipboard_uses_one_data_device` | Regular clipboard works on Hyprland without a second regular device | +| PRIMARY | `primary_selection_uses_w15p` | Password excluded, unchanged selection not reclaimed, serial/seat/offer/self-paste/cancel cases; single data device | +| Selection | `textedit_selection_to_toolbar` | Select a subrange in the paragraph TextEdit, click Bold/colour/underline/spacing in Format and assert only that byte range receives `RangeStyle`, with one undo. Repeat via keyboard/focus surrender. `LineEditor.selection` is populated from cursor_range and used by `LineEditor::style` in `apply_line_format`; right-rail-before-central draw order may protect a path but is not proof. Final fork P7c owns #8763 fix (#8764 remains open/unmerged). #8748 is closed/unmerged and root-only PdfCraft has no child case | +| Web | `web_named_keys_and_non_latin_shortcuts` | Named keys not typed as text; `Ctrl+S` works on Russian layout | +| GPU | existing surface tests | 8600×3423 at 250% fitted to 8192; bad surface errors; pipeline error returns `CreateRenderer` | + +### 8.3 Deliberate broken-behavior probes + +Before accepting each test, temporarily revert the fix and confirm the named test fails: + +- remove `surface_fit` → surface-fit test fails; +- remove `catch_errors` → GPU setup error test fails/panics; +- re-add `display_rtl` to catalog lookups → `i18n::tests::arabic_catalog_stays_logical` fails; +- switch E21 back to character counts or remove E21b payload → IME tests fail; +- enqueue the same UI command on a replayed pass → exactly-once test fails; +- remove the hidden UI paint command for injected input → hidden-input test times out; +- remove an accessible name, or restore `"File ⏵"` as the only label → accessibility/menu tests fail; +- skip uploads on discard → texture/page test fails; +- count passes instead of accepted frames → control timing test answers early. + +## 9. Performance protocol and thresholds + +### 9.1 Measurement identity + +Record for every run: + +- exact source SHA, lockfile SHA-256, `cargo metadata` package IDs and sources; +- features (`CRAFT_FONTS_DIR` set/unset, `PDFCRAFT_SYSTEM_FONTS=0/1`, `PDFCRAFT_RENDERER=wgpu/gl`, `monochrome_emoji_fonts`, `clipboard`); +- fixture hash (`tests/perf.rs::big(500)`, `named_comments(10_000)`, panel fixture); +- OS/kernel, CPU, RAM, GPU/adapter/driver/backend, DPI/scale, window size (1400×900), renderer, profile; +- software adapter (llvmpipe/WARP) versus real GPU; +- host load and concurrent builds. + +Baseline is measured **before** any production change, on `a2ec27b`, in its own checkout/target/evidence. Candidate runs use the same machine, boot, fixture and environment. The first report and every investigated regression include stock-0.36 and stock-0.37 controls where the harness can run them, with all three identities recorded. + +### 9.2 Harness-first method + +The Stage 0 harness must exist on the baseline before PR 1. It has three layers: + +1. **Frame/idle/memory**: open the 500-page fixture and named-comment fixture; warm up, then alternating paired runs. +2. **Targeted engine measurements**: `Context::tessellate` of 120 ASCII labels for #8731; startup phases (instance/adapter/device → pipeline creation → first frame) for #8736 on WARP and a real GPU; font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider enabled/disabled in an isolated fixture for #8512. +3. **Operations**: open + first frame, zoom, page scroll, dock/float, save/reopen. + +Method: + +- Build/run only through `maintenance/task.py run/status/finish` with a dedicated task, source and evidence directory; no direct cargo fallback. Submit each build/test directly; do not nest a multi-resource driver. +- Use release binaries and separate processes for baseline/candidate. +- At least 20 warm-up frames, 1,000 measured frames per revision for frame distributions; 10 warmups/30 timed iterations for steady operations; 20 fresh processes for startup. Record every raw sample; report median/p95/p99 and sample counts. +- Alternate baseline/candidate order; use paired runs, separate allocations, no concurrent builds or noisy workloads. +- Measure idle after settling for 5 s: zero repaints after 3 s, process CPU from `/proc//stat` or platform equivalent. +- Peak RSS from `getrusage(RUSAGE_SELF).ru_maxrss` or `/usr/bin/time -v`. +- Texture/allocation counters from `FullOutput.textures_delta` and `DocView::receive` upload count/bytes; add test-only counters if needed. +- Never infer UI performance from compile duration; never auto-update a golden or snapshot. + +### 9.3 Predefined thresholds + +Relative to the paired baseline on the same machine: + +| Metric | Acceptable candidate | +|---|---| +| Frame median | ≤ baseline median × 1.05 | +| Frame p95 | ≤ baseline p95 × 1.10 | +| Frame p99 | ≤ baseline p99 × 1.15, with at least 1,000 observations | +| Peak RSS | ≤ baseline × 1.10 | +| Idle CPU | ≤ 1% of one core; zero repaints after 3 s | +| Texture bytes/frame | ≤ baseline × 1.05 + 1 MiB | +| Open + first frame | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | +| Operation latency | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | + +Noise policy: if baseline median varies by more than 3% across repetitions, discard that run and repeat on a quiet machine; never relax a threshold to make a candidate pass. If a candidate fails, re-run once, then profile before deciding. Existing debug `BUDGET = 40 ms` smoke gates remain; stricter existing budgets win. Use the suite's 0.05 ms floor only for sub-millisecond measurements. + +## 10. Quality gates and platform matrix + +Required for every PR: + +- `cargo fmt --check` +- `cargo clippy -p pdfcraft-ui-egui -p pdfcraft -p pdfcraft-web --all-targets -- -D warnings` +- `cargo test -p pdfcraft-ui-egui` +- `cargo test -p pdfcraft` +- `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` +- `cargo xtask layers` +- `cargo xtask assets` +- `cargo xtask deny` +- `cargo xtask parity` +- `cargo xtask ci` in CI +- `cargo tree -d`/`cargo metadata` duplicate/source assertions +- native feature assertion for `egui-winit/clipboard` and `monochrome_emoji_fonts` +- source-guard evidence from the immutable winit/egui-wgpu revisions + +Platform rows: + +| Platform | Required evidence | +|---|---| +| Linux X11 | real GUI: panels, docking, control screenshot, clipboard, IME (fcitx5/IBus), wheel/zoom | +| Linux Wayland GNOME | app-drawn frame, window controls, resize edges, panel clipping, PRIMARY | +| Linux Wayland KDE/Sway | server-side frame, same layout and shortcuts, regular clipboard | +| Linux Wayland Hyprland | text clipboard/PRIMARY single-device, screenshot control | +| Windows 11 | real GPU and WARP, mixed-DPI drag, hidden maximized restore, >8192 surface, Korean IME, screenshot, craft-print/GDI build | +| macOS | traffic lights/title-bar drag, ⌘Q save prompt via craft-quit (W14 not opted in), press-and-hold/dictation IME, emoji picker, Korean space | +| FreeBSD | CI or explicitly unresolved; no GUI claim without a real session | +| Web (Chromium/Firefox) | `trunk build`, open a PDF, Arabic UI, shortcuts on a non-Latin layout, image paste, no named-key text leakage | + +Headless `egui_kittest` is not proof of native focus, IME, file pickers, clipboard/PRIMARY or GPU presentation. A row is either backed by real evidence or listed as unresolved. Record actual selected test counts and failures; zero selected tests are not acceptance. + +## 11. Risks and migration traps + +1. **Double bidi.** Adopting E5 while keeping `display_rtl`/`bidi::visual` reverses Arabic/Kurdish twice. Remove preordering in the same language PR. +2. **Panel keys.** 0.37 derives panel IDs from the parent scope; widths reset unless the first compatibility PR migrates `PanelState`. Child widget state may reset once and is measured. +3. **`Ui::scope_id` is the old `Ui::id`.** Rename mechanically; do not switch existing `ui.id().with(...)` state to `unique_id()`. +4. **Multi-pass side effects.** `logic` and `ui` run per pass; edits/undo/render queues must be exactly-once. Do not impose “no commit on discard”. +5. **Hidden windows and stale input.** `run_logic` leaves input events/time stale. Current input belongs in the UI; background work uses monotonic deadlines. +6. **E22 pass-index fallacy.** After a completed `run_ui`, `current_pass_index()` is zero; a first-pass guard does not skip ordinary hidden logic. No E22 scheduler API. +7. **Screenshot assumptions.** A screenshot command itself paints a hidden window; injected input needs `RequestPaintWhileHidden`. Callback completion may be off-thread and has no built-in DPI metadata. +8. **Texture uploads.** Skipping uploads on `will_discard` can lose first-pass textures. Use generation identity and the final merged delta. +9. **Kinetic scroll.** Use real `MouseWheelSource` variants; `Unknown` is normal on Wayland/Windows/X11/web. Keep one document glide. +10. **Font provider policy.** eframe's system-font provider can read many installed faces, including proprietary ones; keep it off and preserve `PDFCRAFT_SYSTEM_FONTS=0`. +11. **Default-font glyph loss.** 0.37 gates emoji fonts; enable `monochrome_emoji_fonts`, add `egui-icons.ttf`, and keep the kittest glyph panic. +12. **Kittest stricter accessibility.** Unnamed inputs fail; fix labels rather than disabling checks. +13. **`WidgetType::TextEdit` does not exist as a `Role`.** It is `Role::TextInput`; `Checkbox` is `Role::CheckBox`. +14. **craft-print drift.** The bump adds `craft-winprint-gdi`, `rgb`, `lpstat -l` descriptions and `Printer.description`; audit all, fix 19 literals and preserve current main's security fixes. +15. **Winit source guards.** Twelve app modules read old vendor paths; move them centrally and use exact git identities. +16. **Snapshot churn is not acceptance.** Review every diff and keep semantic pixel tests. +17. **PRIMARY.** W15P is required and is present in the current winit pin; filtering `MiddleClickPaste` does not avoid the protocol risk and is not the selected design. +18. **IME surrounding text.** The planning-era candidate lacked E21b/P7b; pinned `6d518fc` contains the producer. Native Wayland execution remains unqualified, so do not describe W27 as complete integration. + +## 12. Resolved decisions and external gates + +Decisions made by this plan: + +- **E11:** not required by PdfCraft; epaint's `vello_cpu` 0.2.0 cannot unify with hayro's 0.0.8. Verify with `cargo tree -e features -i vello_cpu`. +- **Kinetic scrolling:** PdfCraft's `fling.rs` owns document motion; ordinary widget `ScrollArea`s keep egui's kinetic default. Use `Unknown`/`Wheel`/`Trackpad`/`Momentum`; macOS custom fling is off. +- **Panels:** migrate persisted `PanelState` keys in PR 1; transient in-panel focus/scroll may reset once only if the upgrade test proves it. +- **PRIMARY:** adopt W15P on the existing winit connection/event queue; no app-level filtering and no second regular data device. +- **RTL:** remove catalog preordering; explicit `TextDirection` for UI labels; `Style::right_to_left` after manual-layout audit; explicit craft-ui dock direction; saved workspace order unchanged. +- **Fonts:** `monochrome_emoji_fonts` on; no broad system provider. +- **Window frame:** `craft_window::Choice` at startup; `PDFCRAFT_TITLEBAR=app|system|desktop`; `window_controls` uses craft-window types; runtime preference remains a `ViewportCommand::Decorations` toggle. +- **Screenshot helper:** PR 1 waits for and directly adopts the reviewed craft-ui helper after CL-2F; no local duplicate. +- **Performance:** harness and baseline precede production; stock-0.37 control accompanies first baseline and regressions. + +External gates that cannot be decided from this host: + +- final approved immutable storytold egui/winit/craft-libs revisions, including W15P and E21b/P7b; +- native Wayland PRIMARY/IME evidence on wlroots/KWin/Mutter; +- Windows real-GPU/WARP and native GUI evidence; macOS and FreeBSD hardware evidence; +- browser runtime and non-Latin keyboard evidence; +- explicit human approval of every branch/tag push, PR or publication. + +## 13. Limitations of this plan + +- No build, test, clippy, fmt, wasm, GUI or performance run was performed. The current tree was read only. +- No compiler diagnostics were generated for this baseline. Historical audit diagnostics are stale. +- Upstream status comes from the provided `upstream-discussions.json` snapshot and local refs; it is not a live GitHub query. #8763 and #8764 are an open issue/PR, not release fixes. #8748 is closed and N/A because PdfCraft uses only `ViewportId::ROOT`. +- No native Wayland compositor, macOS or Windows session was available; PRIMARY, IME, focus and real-GPU behavior remain unverified. +- No performance baseline has been measured. The thresholds in §9 are predeclared relative limits; the absolute baseline must be recorded before implementation. +- The exact publication revisions for egui, winit and craft-libs remain external prerequisites, not user choices. +- Only `docs/egui-037-migration-plan.md` was written. No production file, manifest, lockfile, vendor tree or test was changed. + +## 14. Source index used for this plan + +PdfCraft: + +- `Cargo.toml`, `Cargo.lock`, `deny.toml`, `ATTRIBUTION.toml`, `xtask/src/assets.rs`. +- `apps/pdfcraft/Cargo.toml`, `apps/pdfcraft-web/Cargo.toml`, `apps/pdfcraft/src/main.rs`, `apps/pdfcraft/src/decorations.rs`, `apps/pdfcraft/tests/it/*.rs`. +- `crates/ui-egui/Cargo.toml`, `crates/ui-egui/src/lib.rs`, `control.rs`, `canvas.rs`, `chrome.rs`, `window_controls.rs`, `window_state.rs`, `theme.rs`, `system_fonts.rs`, `motion.rs`, `recovery.rs`, `fling.rs`, `wheel_pager.rs`, `wheel_boost.rs`, `scroll_speed.rs`, `bidi.rs`, `widgets.rs`, `print_ui.rs`. +- `crates/print/src/tests.rs`, `crates/ui-egui/tests/it/*.rs`, `crates/ui-egui/tests/perf.rs`, `crates/ui-egui/examples/shot.rs`. +- `vendor/README.md`, `vendor/winit`, `vendor/egui-wgpu`, retained `vendor/hayro*`, `vendor/lopdf`, `vendor/vello_common`. + +Target/reference source: + +- egui 0.37 `841a328b`, planning-era candidate `a4f9a17f` (current pin `6d518fc`): `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `Ui::scope_id`/`unique_id`, `UiBuilder`, `PanelState`/`Panel::id`, `ScreenshotCallback`, `MouseWheelSource`, `ScrollStyle`/`ScrollArea`, `menu::SubmenuButton::arrow_atom`, `epaint_default_fonts`. +- winit vendor 0.30.13, planning-era candidate `8b35388b` and current pin `a517b384`: `PATCHES.md`, `platform/wayland.rs::set_ime_surrounding_text`, per-seat clipboard/data-device APIs. +- craft-libs `3832c47` (planning-era; ancestor of the current `25da131` pin), PR 199 `b68de8c`/`8614e4a`/`ec8840a`: `crates/ui`, `crates/window`, `crates/print`, `docs/egui-037-suite-rollout.md`. +- Shared evidence: `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, `upstream-discussions.json`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +The owned checkout now HEADs at `a2ec27b4775f53c40cad3456936d9406611e3a8d`; `57e531ccca0047597809748b5cb4276336d6051a` and the earlier baseline SHAs above remain historical review evidence, and both refs remain reachable. Current integration uses this checkout and its qualification lock; paired measurements must record the exact source and lock they used. + +Preserve document-folder native dialogs, colour popup close policy, panel-header spacing, bookmark contrast, and org_drag cancellation after document changes. Preserve new integration regressions and generate fresh baseline geometry. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation amendment — 2026-10-24 + +**Historical note (superseded by the 2026-10-25 qualification state).** The migration is implemented locally but remains unpublished. At the time of this amendment, `egui`/`eframe`/`egui-wgpu` 0.37.0, the reviewed winit worktree and the shared craft-libs helpers resolved through `local-integration-config.toml`; the checked-in `Cargo.toml`/`Cargo.lock` did not yet pin them. The current tree pins egui `6d518fc`, winit `a517b38` and craft-libs `25da131` (craft-ui, craft-quit and craft-print) directly, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` (see the top section). The repository quality gates passed as recorded below; publication still requires fresh human approval. No push, PR or release is authorized by this amendment. + +The font contract now includes the native-only Simplified Chinese face: the pinned craft-fonts input supplies `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite, OFL-1.1), `crates/fonts/build.rs` embeds it on native builds only under a 16 MiB cap, and a `CRAFT_FONTS_REQUIRED` build fails rather than skipping an absent or over-cap required Chinese face. Traditional Chinese is served by an installed `Hant` face placed before the Japanese faces even when LXGW is embedded. CI, release and FreeBSD keep an immutable craft-fonts commit pin; the current candidate is recorded in the task evidence and the pin must be updated only to a published central commit. + +Fallback faces now use egui 0.37's `FontTweak::fallback_align = FallbackAlign::Baseline` for the existing one-baseline intent (the metric-derived `y_offset_factor` compensation is gone). Control-channel synthetic input that times out after a press keeps one bounded release batch, so a key, pointer button or modifier cannot stay held when the window resumes. Accepted-frame counting remains one count per presented frame, verified at `max_passes` above two. diff --git a/docs/gaps.md b/docs/gaps.md index 4349df5922..56ec3ae748 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (gap 6 #3673: Character encoding CJK mapping routes corrected; no readiness re-estimate) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -37,7 +37,7 @@ update the numbers. |---|---|---|---|---| | 2 | Rendering is borrowed and its fidelity unmeasured | feature, spec | 110–190 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 3 | Editing existing text is fragile on real files | feature | 100–180 | [target-app-parity.md](target-app-parity.md) | -| 5 | Chinese UI shows missing-glyph boxes in releases | localization | 6–12 | [localization-parity.md](localization-parity.md) | +| 5 | Chinese UI lacks a web glyph face | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | | 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | @@ -46,7 +46,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -117,18 +117,19 @@ update the numbers. how a document looks. - **Estimate:** 100–180 h (the longest feature pole, M7). **Doc:** [target-app-parity.md](target-app-parity.md). -### 5. Chinese UI shows missing-glyph boxes in releases +### 5. Chinese UI lacks a web glyph face -- **Missing:** a Simplified Chinese (`Hans`) UI face in release builds and a working fallback. +- **Missing:** a Simplified Chinese (`Hans`) UI face in the web build and a working fallback. Native release builds embed LXGW WenKai Lite from the pinned craft-fonts native Simplified Chinese manifest. - **Evidence:** [#826](https://github.com/storytold/pdfcraft/issues/826), [#688](https://github.com/storytold/pdfcraft/issues/688), [#728](https://github.com/storytold/pdfcraft/issues/728), [#758](https://github.com/storytold/pdfcraft/issues/758), - [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). craft-fonts has Noto Sans - CJK SC, but at 16.4 MB it is too large to embed whole; the interface needs a cut of it - (storytold/craft-libs#213). -- **Impact:** the second-largest language group sees a broken interface although the catalog is 97% - translated. + [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak) predate the native face; the + web build still embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. + craft-fonts has Noto Sans CJK SC, but at 16.4 MB it is too large to embed whole; a web face needs + a cut of it (storytold/craft-libs#213). +- **Impact:** the second-largest language group sees a broken interface in the web build although + the catalog is 97% translated. - **Estimate:** 6–12 h (cut CJK faces through craft-fonts-build plus the fallback chain). **Doc:** [localization-parity.md](localization-parity.md). @@ -305,14 +306,18 @@ update the numbers. to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean +### 14. Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean -- **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships - Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). +- **Missing:** end-to-end qualification of right-to-left interface workflows on native platforms + and in the browser (typed RTL text, IME composition, clipboard/selection and complex multi-line + bidirectional text); catalogs for Hindi, Indonesian and Korean (Acrobat ships Korean; it ships + Arabic in its Middle East edition; it ships none of Hindi or Indonesian). The logical catalog, + explicit right-to-left label direction and mirrored stock chrome are implemented and + unit-tested; complex RTL workflows are not yet accepted. Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` like other non-English languages (no translated help; engine/OS details stay English). -- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. +- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned (end-to-end + qualification pending). - **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). @@ -398,6 +403,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 04434712a3..fee2ba3e7b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Brazilian Portuguese now covers the 2,674-key catalog union; twelve-language mean still rounds to ≈68%; Spanish 100% of the current key union) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -13,7 +13,7 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R **That union is a moving target**: every change that adds a string widens it, so a count is only comparable with others measured on the same date, and a catalog that reached 100% falls behind without losing a single translation. Every figure here therefore carries its date. The current - union is **2,674** keys as of 2026-10-20, against the **2,143-key snapshot** of 2026-10-10; earlier + union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier revisions quote the union of their own date (see the revision history), not this one. The twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is @@ -34,22 +34,22 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | -| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work, but **release builds have no Hans face**: many characters show as boxes (#826, #688, #728, #758; Flatpak #689) | no | partial | yes | 6–12 | -| Spanish | `es` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | -| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); no Devanagari face in craft-fonts | no | none | no | 10–18 | -| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Letters joined and lines reordered at load, but **layout not mirrored**, wrapped lines break at the wrong end, typed text runs left to right (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | +| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 | +| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | | French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | -| Portuguese (Brazil) | `pt-br` | 2,674 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | | Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | +| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | **Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only 60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** -to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and @@ -63,33 +63,34 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | | Russian | `ru` | 2,043 (95.3%) | partial | yes | | Bulgarian | `bg` | 2,094 (97.7%) | partial | no | -| Czech | `cs` | 2,674 entries (every key of the other catalogs, the engine's sentences, weekdays and certificate fields) | partial | yes | +| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes | | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | -| Kazakh | `kk` | 2,327 entries | partial | no | -| Polish | `pl` | 2,287 entries | partial | yes | -| Slovenian | `sl` | 2,324 entries | partial | no | -| Greek | `el` | 2,341 entries | partial | no | -| Turkish | `tr` | 2,313 entries | partial | yes | -| Lithuanian | `lt` | 2,674 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -| Finnish | `fi` | 2,674 entries | partial; AI-assisted, native proofreading pending | yes | -| Albanian | `sq` | 2,344 entries | partial; not yet reviewed by a native speaker | no | -| Catalan | `ca` | 2,592 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | -| Romanian | `ro` | 2,673 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -| Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | -| Persian | `fa` | 2,776 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes | +| Kazakh | `kk` | 2,539 entries | partial | no | +| Polish | `pl` | 2,611 entries | partial | yes | +| Slovenian | `sl` | 2,536 entries | partial | no | +| Greek | `el` | 2,545 entries | partial | no | +| Turkish | `tr` | 2,525 entries | partial | yes | +| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | +| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | +| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | +| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | **20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12–14 (Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Added Persian (`fa`, 2,776 entries, every bundled key); right-to-left like Arabic and Kurdish with the same layout limits; AI-assisted, not yet native-reviewed | +| 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged | +| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | diff --git a/docs/localization.md b/docs/localization.md index 234542c755..e2ce23ef54 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -14,56 +14,49 @@ The control channel exposes the setting through `ui.set`: The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Kurdish, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Romanian, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each), and Czech (2,674 entries) covers the same surface. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs carry most of the current localizable surface (roughly 2,400–2,800 entries each); Czech (2,794 entries) has all but four keys, and Finnish, Vietnamese, Catalan, Brazilian Portuguese, Spanish, Lithuanian and Romanian have all 2,798. Persian has 2,785. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). -The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. +The Ukrainian catalog has 2,530 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Vietnamese catalog has 2,798 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,798 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. -The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. +The Catalan catalog has 2,798 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. -The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Romanian catalog (`ro`, Română) has 2,798 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. +The Norwegian Bokmål catalog has 2,707 entries, all but 91 of the current key union, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases includes the `Hebr` face, so releases draw the missing letters and accept Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and in a build without craft-fonts every Chinese character does, on the web and on a desktop machine with no installed Chinese face). Until the interface embeds a `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese uses the pinned craft-fonts input too. On native builds, `fonts/simplified-chinese-manifest.txt` adds LXGW WenKai Lite as a `Hans` face (at most 16 MiB, never embedded on the web); Simplified Chinese puts the `Hans` faces first, so a line never mixes faces with different baselines. Traditional Chinese has no embedded `Hant` face: the desktop app places a Traditional Chinese face already installed on the machine (Microsoft JhengHei on Windows, PingFang TC on macOS, a Noto CJK TC or WenQuanYi Zen Hei on Linux) before the Japanese faces, even when LXGW is bundled, because a Simplified-only face cannot supply Traditional-only characters. Without an embedded `Hans` face, Simplified Chinese likewise uses an installed Simplified Chinese face (Microsoft YaHei, PingFang SC, …) placed before Japanese (#826). The web build has no installed fonts to fall back on, so it shows a replacement box for characters the Japanese face lacks. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). A build supplied with a craft-fonts `Telu` face draws Telugu and retains that face in the web payload; egui shapes its conjuncts. The release-pinned craft-fonts input has no Telugu face, so its Telugu labels show replacement boxes. -The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). +The Brazilian Portuguese catalog has 2,798 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). The Albanian catalog (`sq.tsv`, interface name **Shqip**) is written in Standard Albanian with the imperative for commands (`Ruaj`, `Hap`, `Fshi`). Albanian uses two plural forms like English: 1 is singular and every other count, including 0, is plural (`1 faqe`, `2 faqe`). Month and weekday names are lowercase (`mars`, `e shtunë`), and the letters `ë` and `ç` are covered by the bundled Latin faces. It has not been reviewed by a native Albanian speaker yet. ## Arabic and right-to-left text -The Arabic catalog has 2,137 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. +The Arabic catalog has 2,783 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. -egui joins the letters of an Arabic word and runs them right to left, but it places the words of a line left to right in the order they are written. So catalog entries are written in logical (typing) order, and `i18n` puts each one into display order when the catalog loads (`bidi::display_rtl`): the line reads right to left, Latin words and numbers inside it read left to right, `{name}` placeholders and keyboard shortcuts stay whole, spaces at either end stay where the code concatenates, and a short caption keeps its colon on the right, beside the widget it captions. Lookups (`tl!`, `tr`, `trn`) therefore return display-order text for Arabic; `i18n::fmt` inserts values as they are. The command search matches a query of several Arabic words word by word. +egui 0.37's epaint shapes every line with the Unicode bidirectional algorithm (UAX #9). Catalog entries stay in logical (typing) order and are never preordered: `tl!`, `tr` and `trn` return logical text, `bidi::display_rtl` and `bidi::visual` exist only for the tests that prove the old preordering is gone, and epaint joins and reorders Arabic on every line. Interface labels use an explicit `TextDirection::RightToLeft` in an Arabic, Kurdish or Persian interface, so a label whose first strong character is Latin or a `{placeholder}` still reads right to left. File and path labels use `TextDirection::LeftToRight`, so an Arabic file name keeps `.pdf` at its visual end; document-authored text uses `Auto`. Stock chrome is mirrored through `Style::right_to_left` (menus, combo boxes, scroll bars, headers), and `i18n::fmt` inserts values in one pass. The command search matches a query of several Arabic words word by word. -What Arabic does not have yet: +Wrapped paragraphs break between words in logical order and epaint reorders each resulting line, so a wrapped label reads in the right direction from the first line down. Text typed into a field is shaped and reordered while editing, and the stored value stays logical. -- **The layout is not mirrored.** Panels, menus, toolbars and alignment are as in the left-to-right languages, with Arabic labels in them. -- **A wrapped paragraph breaks at the wrong end.** A label too long for its line continues on the next line with its *first* words, so wrapped descriptions and tooltips read from the last line up. Single-line labels are right. -- **Text you type into a field** (search, comments, form values) has joined letters but its words run left to right while you edit it. What is stored is correct. -- Labels built by joining translated pieces in code (a list such as `A · B · C`) keep the code's left-to-right order. +Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` selects the explicit right-to-left direction; epaint reorders it, so it behaves as Arabic does above. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). -These need bidirectional support in the text layout itself, not in the catalog. +The Persian catalog (`fa.tsv`, interface name **فارسی**) has 2,785 entries, covering 2,785 of the current 2,798-key union; the 13 keys added after it was written still show English. It is written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish: `rtl: true` selects the explicit right-to-left direction, the entries stay in logical order and epaint reorders each line, and the stock chrome is mirrored as it is for Arabic. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. -Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). +Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic and Hebrew faces. Its Noto Sans CJK SC face is 16.4 MB and is left out by the general CJK cap; native builds take the separately pinned Simplified Chinese face (LXGW WenKai Lite) from `fonts/simplified-chinese-manifest.txt`, and the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. -The Persian catalog (`fa.tsv`, interface name **فارسی**) covers the current 2,776-key union, written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish (`rtl: true`, `bidi::display_rtl` at load; entries are written in logical order here) and has the same four limits above. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. +File names and document titles use the craft-fonts `Arab` face (Noto Sans Arabic) for Arabic and the `Hebr` face (Noto Sans Hebrew) for Hebrew when those faces are included in the build input; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order. -Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). - -File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. - -Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Catalog coverage does not guarantee glyph coverage. Native builds embed LXGW WenKai Lite as the Simplified Chinese face from the pinned craft-fonts input (at most 16 MiB), while the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces, so Chinese characters none of those faces has show replacement boxes on the web. Traditional Chinese has no embedded face and uses a `Hant` face installed on the machine, placed before the Japanese faces even when LXGW is bundled. German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. @@ -98,3 +91,5 @@ Error details that come from the operating system, and faults rather than mistak 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. + +The egui 0.37 migration pins craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. That manifest supplies Arabic, Hebrew, Thai and Devanagari faces selected for the web payload, but contains no Telugu face. The build code accepts a `Telu` row when a future input provides one; it does not establish Telugu glyph coverage with the current pin. diff --git a/docs/releasing.md b/docs/releasing.md index ec2e5853b3..496ef4a672 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -148,7 +148,18 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - The portable zip holds both executables, the README, the licences, the OFL licence of each embedded craft-fonts family, and `portable.txt`. That marker beside `pdfcraft.exe` keeps the settings, logs, crash recovery and new digital IDs in `PdfCraftData\` next to the exe instead of - `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). + `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). The MSI carries the same + OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or + `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that + the compiled MSI has OFL notice rows when expected (and none for a no-font build). + Before either package is created, `font-notices.ps1` validates every declared OFL notice in + `fonts/manifest.txt` and the native `fonts/simplified-chinese-manifest.txt` when present + (required when `CRAFT_FONTS_REQUIRED` is set). Missing or empty family notices fail packaging; + one family's notice cannot cover another. The check conservatively includes declared faces + omitted by build-time size caps. `test-font-notices.ps1` exercises these failures in CI. + The supplied checkout must match the existing binaries when using `-SkipBuild`. + That presence check does not establish byte-for-byte payload or installation coverage; + final package qualification still checks all notices against the staged files. - **Signing:** `packaging/windows/sign.ps1` signs both executables and the MSI with `signtool` (SHA-256, RFC 3161 timestamp), from a `.pfx` (`WINDOWS_CERTIFICATE`) or Azure Trusted Signing (`AZURE_*`), whichever is configured. It is the one place to change when signing changes. diff --git a/docs/roadmap.md b/docs/roadmap.md index 832fabf54f..40fa711c24 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -135,15 +135,15 @@ In order. Each item names its entry in [gaps.md](gaps.md). Encrypted output that Acrobat opens shipped with #836 (#774). 2. **Fix the crash and wrong-result reports** filed against shipped features (gap 6). The first batch (#799–#821, including the panic in #816) is fixed. -3. **A Chinese UI face in releases** (gap 5, #826). +3. **A Chinese UI face in the web build** (gap 5, #826). 4. **A fidelity harness against Acrobat, then the renderer decision** (gaps 12 and 2). Owner decision needed: our own M2 devices, or #841's proposal as the bridge. 5. **Editing existing content** (gap 3, M7): fonts, subsets, reflow, CJK and RTL. 6. **Pro workflows:** OCR languages (gap 7), Office export/import (gap 8), Preflight and PDF/A/X/UA (gap 9), signatures with tokens, timestamp servers and online LTV (gap 10), accessibility tagging (gap 11). -7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), right-to-left layout and the - missing languages (gap 14), keyboard-only operation, a screen-reader audit. +7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), end-to-end right-to-left + qualification and the missing languages (gap 14), keyboard-only operation, a screen-reader audit. Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. @@ -199,7 +199,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -208,7 +208,7 @@ total matches the parity estimate. | Rank | Next milestone | Closes | Estimate (h) | |---|---|---|---| | 0 | Convert and share: Create PDF from Office, Word export keeps layout | part of gap 8 | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese UI face (web printing done) | gaps 5, 6 | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese web UI face (web printing done) | gaps 5, 6 | 50–90 | | 2 | Fidelity harness and renderer decision (M2) | gaps 2, 12 | 110–190 | | 3 | Editing existing content (M7) | gap 3 | 100–180 | | 4 | Pro conversion: OCR languages and clean-up, Office export/import (M10) | gaps 7, 8 | 95–175 | @@ -242,6 +242,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. Synthetic measurement and optimization regressions cover the change. Estimates unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b019bdf3d6..6c7920e33c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding CJK mapping routes corrected; scores and estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -183,7 +183,7 @@ features total and are shown for orientation; the others add to it. | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | | Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | @@ -249,7 +249,7 @@ stage set to pre-alpha; see the revision history.) | Beta requirement | Hours | |---|---| | An interop suite that opens our outputs in other readers (encrypted output already opens in Acrobat, #774, #836; hours not re-estimated) | 8–15 | -| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); Chinese UI faces in releases | 80–140 | +| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); a Chinese web UI face | 80–140 | | Editing existing text robust on real files: subset fonts, CJK, RTL, rotated text | 70–120 | | Printing on Windows and the web | 15–30 | | OCR in the major languages (accents, Cyrillic, CJK), deskew and rotation | 35–60 | @@ -294,6 +294,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | | 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index b3b9c53849..8ae41ae976 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -48,6 +48,7 @@ modules: build-commands: - cargo --offline build --release --locked -p pdfcraft - install -Dm755 target/release/pdfcraft /app/bin/pdfcraft + - install -Dm644 NOTICE /app/share/doc/pdfcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.desktop /app/share/applications/ai.storyteller.pdfcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.mime.xml /app/share/mime/packages/ai.storyteller.pdfcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index df292455e8..fd486c392b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -153,7 +153,6 @@ if has appimage; then fi APPDIR="$WORK/PdfCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/pdfcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 2e2ffc83f5..50701e0c16 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/pdfcraft" "$APP/Contents/MacOS/PdfCraft" cp "$ROOT/assets/app-icon/pdfcraft.icns" "$APP/Contents/Resources/PdfCraft.icns" +# Required embedded-font notices are independent of optional font inputs. +cp "$ROOT/NOTICE" "$APP/Contents/Resources/NOTICE" # The standard About panel (PdfCraft menu > About PdfCraft) shows Credits.rtf under the version: # the website and Discord links the in-app About dialog has (#1287). cp "$HERE/Credits.rtf" "$APP/Contents/Resources/Credits.rtf" diff --git a/packaging/windows/font-notices.ps1 b/packaging/windows/font-notices.ps1 new file mode 100644 index 0000000000..6d8fdc5a1e --- /dev/null +++ b/packaging/windows/font-notices.ps1 @@ -0,0 +1,48 @@ +# Validate one non-comment manifest row before adding its family notice. +function Get-CraftFontNoticeRow([string] $rootPath, [string] $manifest, [string] $line) { + $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } + # Manifest notice paths must name a family-local file, never escape the font input. + $relative = $fields[5].Replace('\', '/') + if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } + $family = $Matches[1] + $fontPath = $fields[2].Replace('\', '/') + if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { + throw "Font and notice must belong to the same family directory: $fontPath / $relative" + } + $notice = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { + throw "Missing or empty OFL notice for $($fields[0]): $notice" + } + return @{ Family = $family; Notice = Get-Item -LiteralPath $notice } +} + +# Validate every declared native font notice before either Windows package is created. +# Checking all declared faces is deliberately conservative: size-capped faces may not be embedded. +function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) { + $rootPath = (Resolve-Path -LiteralPath $FontRoot).Path + $notices = @{} + foreach ($name in 'manifest.txt', 'simplified-chinese-manifest.txt') { + $manifest = Join-Path $rootPath "fonts/$name" + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + if ($name -eq 'manifest.txt' -or $RequireChinese) { throw "Missing font manifest: $manifest" } + continue + } + foreach ($line in Get-Content -LiteralPath $manifest) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $entry = Get-CraftFontNoticeRow $rootPath $manifest $line + $notices[$entry.Family] = $entry.Notice + } + } + if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" } + foreach ($family in @($notices.Keys | Sort-Object)) { $notices[$family] } +} + +# MSI FileName may be short|long. Compare the long names with the staged inventory. +function Assert-CraftFontNoticeNames([string[]] $Expected, [string[]] $Actual) { + $names = @($Actual | ForEach-Object { ($_ -split '\|')[-1] }) + foreach ($name in $Expected) { + if ($name -notin $names) { throw "The MSI is missing staged font notice: $name" } + } +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 524280b971..dccc21f559 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -16,16 +16,30 @@ .EXAMPLE pwsh packaging/windows/package.ps1 -Arch x64 - pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild + pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild # set CRAFT_FONTS_DIR, or -BuiltWithoutCraftFonts pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools #> param( [ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64', - [switch] $SkipBuild + [switch] $SkipBuild, + # -SkipBuild only: the existing binaries were built without CRAFT_FONTS_DIR, so they embed no + # craft-fonts faces. Without CRAFT_FONTS_DIR this assertion is required: package.ps1 cannot + # otherwise know whether the binaries need the fonts' OFL notices. + [switch] $BuiltWithoutCraftFonts ) $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +if ($BuiltWithoutCraftFonts -and -not $SkipBuild) { + throw '-BuiltWithoutCraftFonts describes an existing build made without CRAFT_FONTS_DIR; use it only with -SkipBuild.' +} +if ($BuiltWithoutCraftFonts -and $env:CRAFT_FONTS_DIR) { + throw '-BuiltWithoutCraftFonts cannot be combined with CRAFT_FONTS_DIR.' +} +if ($SkipBuild -and -not $env:CRAFT_FONTS_DIR -and -not $BuiltWithoutCraftFonts) { + throw 'package.ps1 -SkipBuild needs CRAFT_FONTS_DIR to stage the OFL notices of embedded craft-fonts, or -BuiltWithoutCraftFonts when the binaries were built without it.' +} + function Invoke-Native([string] $What, [scriptblock] $Block) { Write-Output "==> $What" & $Block @@ -108,17 +122,38 @@ if (-not (Test-Path (Join-Path $Models 'ATTRIBUTION.txt')) -or -not (Get-ChildIt throw "no OCR models in $Models after cargo xtask models" } +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + # Validate both native manifests before copying anything. One family's notice cannot + # stand in for another's; retain complete files, including copyright and reserved names. + $fontNotices = @(Get-CraftFontNotices $env:CRAFT_FONTS_DIR (Test-Path Env:CRAFT_FONTS_REQUIRED)) + foreach ($ofl in $fontNotices) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "pdfcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'pdfcraft.wxs') -arch $Arch ` (Join-Path $PSScriptRoot 'installer-ui.wxs') ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "ModelsDir=$Models" -d "IconPath=$(Join-Path $Root 'assets\app-icon\pdfcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so # its Windows Installer database handle is gone before signtool opens the MSI. -Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi } +Invoke-Native 'test-msi' { + & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` + -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs +} # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi @@ -136,13 +171,10 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } -# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship their -# licences, fonts\\OFL.txt -> OFL-.txt. -if ($env:CRAFT_FONTS_DIR) { - Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts') -Directory -ErrorAction SilentlyContinue | ForEach-Object { - $ofl = Join-Path $_.FullName 'OFL.txt' - if (Test-Path $ofl) { Copy-Item $ofl (Join-Path $Portable "OFL-$($_.Name).txt") } - } +# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship the +# same verified notices the MSI carries, fonts\\OFL.txt -> OFL-.txt. +if ($HasCraftFontLicences -eq 1) { + Copy-Item (Join-Path $Docs 'OFL-*.txt') $Portable } # portable.txt beside pdfcraft.exe switches on portable mode: settings, logs, recovery files and new # digital IDs go to PdfCraftData\ next to the exe instead of %APPDATA% (#157; see diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index d96f2cd78d..65e76f254b 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -12,6 +12,9 @@ PdfCraft arrive on its command line. --> + + + + + + + + + + + diff --git a/packaging/windows/test-font-notices.ps1 b/packaging/windows/test-font-notices.ps1 new file mode 100644 index 0000000000..4d2c81b08b --- /dev/null +++ b/packaging/windows/test-font-notices.ps1 @@ -0,0 +1,43 @@ +param([string] $CraftFontsRoot) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'font-notices.ps1') +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('pdfcraft-font-notices-' + [guid]::NewGuid()) +function Assert-Rejected([scriptblock] $Action, [string] $Message) { + $rejected = $false + try { & $Action | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw "Expected rejection: $Message" } +} +try { + foreach ($family in 'core', 'chinese') { + New-Item -ItemType Directory -Path (Join-Path $fixture "fonts/$family") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $fixture "fonts/$family/OFL.txt") -Value 'Synthetic notice fixture' + } + $core = 'Core | Regular | fonts/core/face.ttf | Latn | OFL-1.1 | fonts/core/OFL.txt' + $chinese = 'Chinese | Regular | fonts/chinese/face.ttf | Hans | OFL-1.1 | fonts/chinese/OFL.txt' + Set-Content (Join-Path $fixture 'fonts/manifest.txt') @('# comment', $core, $core) + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + if (@(Get-CraftFontNotices $fixture $true).Count -ne 2) { throw 'Expected both unique family notices' } + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', 'fonts/core/OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'another family cannot supply this notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + Remove-Item (Join-Path $fixture 'fonts/chinese/OFL.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'one remaining OFL cannot cover the Chinese family' + Set-Content (Join-Path $fixture 'fonts/chinese/OFL.txt') '' -NoNewline + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'empty family notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', '../OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'notice path escapes font input' + Remove-Item (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'required Chinese manifest missing' + if (@(Get-CraftFontNotices $fixture).Count -ne 1) { throw 'Optional Chinese manifest may be absent' } + Set-Content (Join-Path $fixture 'fonts/manifest.txt') 'malformed' + Assert-Rejected { Get-CraftFontNotices $fixture } 'malformed metadata' + Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('SHORT.TXT|OFL-core.txt', 'OFL-chinese.txt') + Assert-Rejected { Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('OFL-core.txt') } 'MSI lost one staged notice' + if ($CraftFontsRoot) { + $actual = @(Get-CraftFontNotices $CraftFontsRoot $true) + Write-Output "Verified $($actual.Count) notices from the actual native manifests" + } + Write-Output 'Font notice regression checks passed' +} finally { + Remove-Item -LiteralPath $fixture -Recurse -Force +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 45e1147fa5..24a4c6d75a 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -4,7 +4,13 @@ .EXAMPLE pwsh packaging/windows/test-msi.ps1 dist/release/pdfcraft-0.2.1-windows-x64.msi #> -param([Parameter(Mandatory)] [string] $Path) +param( + [Parameter(Mandatory)] [string] $Path, + # 1 when package.ps1 staged craft-fonts OFL notices, 0 when the binaries embed no craft-fonts. + # -1 (the default) skips the check when the MSI is inspected by hand. + [int] $CraftFontLicences = -1, + [string] $ExpectedFontNoticesDirectory +) $ErrorActionPreference = 'Stop' $Installer = New-Object -ComObject WindowsInstaller.Installer $Database = $Installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0) @@ -69,6 +75,30 @@ foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { } $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' + +# Embedded craft-fonts must ship their OFL notices; a build without them must not claim any. +# Check the compiled File table, not only the WiX source, so a staging or component-group mistake +# cannot silently omit the licences. +$fontNotices = @() +$fileView = $Database.OpenView('SELECT `FileName` FROM `File`') +try { + [void] $fileView.Execute() + while ($record = $fileView.Fetch()) { + $name = $record.StringData(1) + if ($name -like '*OFL-*') { $fontNotices += $name } + } +} finally { [void] $fileView.Close() } +if ($CraftFontLicences -eq 1 -and $fontNotices.Count -eq 0) { + throw 'the MSI carries no craft-font OFL notices' +} +if ($CraftFontLicences -eq 0 -and $fontNotices.Count -gt 0) { + throw "the MSI carries craft-font notices that packaging did not stage: $($fontNotices -join ', ')" +} +if ($ExpectedFontNoticesDirectory) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + $expected = @(Get-ChildItem -LiteralPath $ExpectedFontNoticesDirectory -File -Filter 'OFL-*.txt' | ForEach-Object { $_.Name }) + Assert-CraftFontNoticeNames $expected $fontNotices +} $status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProgress'' AND `Control` = ''Status''' 1 Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message' Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9144112544..8d2f3ecf67 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -10012,8 +10012,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms. Catalogs stay logical and egui 0.37's epaint applies UAX #9 reordering; Arabic and Kurdish labels take an explicit right-to-left paragraph direction and stock chrome is mirrored through Style::right_to_left. Missing: native and browser end-to-end RTL input, IME and complex bidirectional workflows are not yet qualified; Czech dialogs, Chinese and Telugu font delivery (native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Chinese face beyond BIZ UDPGothic and the release-pinned craft-fonts has no Telugu face), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" @@ -10145,7 +10145,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] -notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." +notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Simplified Chinese has a native release LXGW WenKai Lite face but the web build has no Hans face (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic labels stay logical with explicit right-to-left direction and mirrored stock chrome while native/browser/IME RTL workflows are not yet qualified (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] id = "misc.rtl-ui" @@ -10157,7 +10157,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language." +notes = "Implemented and unit-tested: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop), keep logical text and are laid out left to right so the extension stays at the visual end; Arabic and Kurdish interface labels use an explicit right-to-left paragraph direction and stock chrome mirrors through Style::right_to_left (crates/ui-egui/src/bidi.rs, theme.rs); catalogs are never preordered. Still not qualified: native and browser end-to-end RTL input, IME composition, clipboard/selection and complex multi-line RTL workflows. Status stays planned until those workflows are accepted." [[feature]] id = "misc.installers" diff --git a/vendor/README.md b/vendor/README.md index e5f57ec1ad..20fb0c75a8 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,5 +1,27 @@ # Vendored crates +## Window-stack provenance after the egui 0.37 migration + +The checked-in, resolved Cargo graph pins `winit` to storytold/winit +`a517b3846ee8457d84ee4975110bfda6797c44dc` and the egui crates to storytold/egui +`6d518fc799681571963842f95b55a6b68431b15a`. Neither `vendor/winit` nor +`vendor/egui-wgpu` is a root `[patch.crates-io]` path entry. The retained copies +record the app's earlier patches; their source-reading tests establish provenance, +while the resolved forks and their behavioral tests establish runtime behavior. + +The retained winit copy has upstream base `e9809ef54b18499bb4f2cac945719ecc2a61061b`, +as recorded in `vendor/winit/.cargo_vcs_info.json`. Its thirteen patch rows below +match the fork's `PATCHES.md` entries W1–W13 commit for commit (W1 `b357a332`, +W2 `13ca693b`, W3 `fd104a4c`, W4 `3fffb7ce`, W5 `283370ce`, W6 `a82b90fb`, +W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W11 `4df8554b`, +W12 `7facf001`, W13 `5506b1a9`). The reviewed winit fork additionally carries +W14–W27 (W14 is opt-in and is not enabled by PdfCraft). `vendor/egui-wgpu` carries +E2 (`surface_fit`) and E3 (`catch_errors`); the egui-wgpu 0.37 fork retains both, +including its `surface_fit` tests exercised by PdfCraft's behavioral tests. + +Keep both copies while their provenance tests remain in use. Renderer and security +vendors below retain their independent path patches and licenses. + Permissively licensed crates we carry small patches for. Each directory keeps the upstream licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, is covered by a PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases @@ -23,11 +45,18 @@ Other resource categories retain their existing parent-chain lookup. | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | -| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | | hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | +`hayro-postscript` is the crates.io 0.1.0 package plus patch (1). The local Cargo +registry cache metadata for that package (`.cargo_vcs_info.json`) records upstream +VCS commit `a2354aebaf86f5ab13e5fd6b6b0521306488bf9d`. The registry copy and this vendored tree's +source files differ only in `src/object.rs` (the patch above); the vendored tree +omits the registry dotfiles `.cargo_vcs_info.json` and `.cargo-ok`, so the upstream +commit is recorded here. + Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline is returned as `WgpuError::CreateRenderer`, allowing PdfCraft's existing startup OpenGL retry. From 86a4120ae093076f10fef88ce298b92819dd07a1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:00:23 -0400 Subject: [PATCH 06/33] feat: startup update prompt modal and direct self-updating (#4654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: startup update prompt modal and direct self-updating Help ▸ Check for updates and the check at start run through craft-update's Updater, which replaces the old GitHub fetcher and update source. The check at start is opt-in for the desktop app (test harnesses never reach the network), an install holds back while documents are unsaved, and a finished update closes through the app's quit path. craft-update stays out of the wasm build. Ref: storytold/craft-libs#319 * test(updates): the prompt's buttons are DOWNLOAD and LATER * build: pin craft-update to craft-libs main (db19c37) * fix(updates): run the Windows update helper before argument parsing * fix(updates): point the parity evidence at the current update tests --- Cargo.lock | 133 +++++++++++++++++-- Cargo.toml | 1 + apps/pdfcraft/Cargo.toml | 5 +- apps/pdfcraft/src/main.rs | 8 +- apps/pdfcraft/src/updates.rs | 127 ------------------ crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/lib.rs | 2 - crates/ui-egui/src/updates.rs | 198 ++++++++++++----------------- crates/ui-egui/tests/it/updates.rs | 69 ++++++---- parity/acrobat-features.toml | 4 +- 10 files changed, 261 insertions(+), 288 deletions(-) delete mode 100644 apps/pdfcraft/src/updates.rs diff --git a/Cargo.lock b/Cargo.lock index 0a7e03a82e..947dfb0c82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1895,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3327,7 +3352,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3404,6 +3429,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3876,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3971,6 +4008,7 @@ version = "0.14.0" dependencies = [ "chrono", "craft-quit", + "craft-update", "eframe", "egui", "fmv-macos-events", @@ -3979,10 +4017,8 @@ dependencies = [ "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", - "rustls-native-certs", "serde_json", "uds_windows", - "ureq", "wayland-client", "wayland-protocols", "windows", @@ -4407,6 +4443,7 @@ dependencies = [ "chrono", "craft-quit", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -5014,7 +5051,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5551,7 +5588,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5564,7 +5601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5634,7 +5671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5763,6 +5800,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -6295,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6517,7 +6558,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6877,6 +6918,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -6897,7 +6944,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6970,10 +7017,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", ] @@ -7125,6 +7174,16 @@ dependencies = [ "vello_common 0.2.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -7611,7 +7670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7735,6 +7794,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -7971,6 +8039,16 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.31" @@ -8384,6 +8462,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8396,6 +8491,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index cb69da41a3..39d11d1eaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 02d6bc56c5..15ec5d6f68 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -11,8 +11,6 @@ workspace = true [dependencies] # Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's # trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). -ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } -rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } @@ -71,3 +69,6 @@ uds_windows = "=1.2.1" [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 8bc4d32f73..ab6ebf830c 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -41,7 +41,6 @@ mod reopen; mod settings; mod single_instance; mod text_scale; -mod updates; #[cfg(test)] #[path = "windows_manifest.rs"] mod windows_manifest; @@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report @@ -419,7 +423,7 @@ fn app_creator<'a>( { app.motion.source = Some(motion::source()); } - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs deleted file mode 100644 index 686c8cee84..0000000000 --- a/apps/pdfcraft/src/updates.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28). - -use std::time::Duration; - -use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release}; - -const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest"; - -/// The latest release. The answer is untrusted: its size is capped, and only a page under -/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). -pub fn latest_release() -> Result { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) - .build() - .new_agent(); - let mut response = agent - .get(LATEST) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION"))) - // Read error statuses ourselves: a 403 from GitHub usually means its anonymous - // rate limit ran out, and the retry time rides along in a response header. - .config() - .http_status_as_error(false) - .build() - .call() - .map_err(|e| format!("couldn't reach GitHub ({e})"))?; - let status = response.status().as_u16(); - if status == 403 { - let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok()); - return Err(rate_limited(reset, now_unix())); - } - if !(200..300).contains(&status) { - return Err(format!("couldn't reach GitHub (http status: {status})")); - } - let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; - parse(&body) -} - -/// The certificate authorities the operating system trusts. -fn os_roots() -> Result { - let found = rustls_native_certs::load_native_certs(); - let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); - if certs.is_empty() { - return Err("no trusted certificates found on this system".into()); - } - Ok(ureq::tls::RootCerts::new_with_certs(&certs)) -} - -/// The current Unix time in seconds (0 when the clock gives nothing usable). -fn now_unix() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour, -/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset` -/// header, the Unix time the quota refills; `now` is the current Unix time. Anything -/// surprising (no header, not a number, already past) falls back to asking for patience. -fn rate_limited(reset: Option<&str>, now: u64) -> String { - const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0); - match wait { - Some(wait) => { - let minutes = wait.div_ceil(60).max(1); - let plural = if minutes == 1 { "" } else { "s" }; - format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}") - } - None => FALLBACK.into(), - } -} - -fn parse(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; - let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); - let url = v["html_url"] - .as_str() - .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) - .unwrap_or(RELEASES_PAGE) - .to_string(); - Ok(Release { version, url }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_are_read_and_only_our_release_pages_are_offered() { - let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap(); - assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() }); - for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] { - let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); - assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); - } - assert!(parse(r#"{"message":"Not Found"}"#).is_err()); - assert!(parse("").is_err()); - } - - #[test] - fn rate_limit_messages_name_the_wait() { - let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes"; - assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited); - // Rounds up: 61 seconds out reads as 2 minutes. - assert_eq!( - rate_limited(Some("1800000061"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes" - ); - assert_eq!( - rate_limited(Some("1800000060"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute" - ); - // No header, not a number, or already past: ask for patience instead of a time. - let later = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] { - assert_eq!(rate_limited(bad, 1800000000), later); - } - } - - /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`). - #[test] - #[ignore = "needs network access"] - fn github_answers_with_the_latest_release() { - let r = latest_release().unwrap(); - assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); - assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); - } -} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b155d117b2..c096254995 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -42,6 +42,7 @@ unicode-bidi = "0.3" skrifa = "0.44" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } # Control-channel tokens. getrandom = "0.4" # Create ▸ Clipboard (images; already in the tree through egui-winit). @@ -83,6 +84,7 @@ js-sys = "0.3" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] +craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } # Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4ee683d4a..79f46bee7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -700,7 +700,6 @@ pub struct PdfCraftApp { pub fill_text_size: f64, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). - pub update_source: Option, pub(crate) updates: updates::Updates, /// Lists the Print dialog's printers in the background (the desktop app sets it; see /// [`PrinterSource`]). @@ -1137,7 +1136,6 @@ impl PdfCraftApp { flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, dialog: None, - update_source: None, updates: updates::Updates::default(), printer_source: None, options_source: None, diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs index 4e7957e15e..fa59f9a89f 100644 --- a/crates/ui-egui/src/updates.rs +++ b/crates/ui-egui/src/updates.rs @@ -1,12 +1,8 @@ -//! Help ▸ Check for updates (issue #28): ask for the latest release and offer its download page. -//! -//! The desktop app supplies how to ask ([`PdfCraftApp::update_source`]), so this crate has no -//! network code; without a source (the web build, tests) the command opens the releases page. -//! PdfCraft never downloads or installs anything itself: the user downloads the new version. -//! It asks only when the user does: there is no check at start (the owner's decision). - -use std::sync::Arc; +//! Help ▸ Check for updates and the check at start, through `craft_update`: the flow itself is its +//! `Updater`; this module supplies the suite, the quit path and the status dialog. +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, CheckSource, Status, Updater}; use egui::{Align, Layout}; use crate::{PdfCraftApp, theme, widgets}; @@ -14,108 +10,102 @@ use crate::{PdfCraftApp, theme, widgets}; /// Where every PdfCraft release is listed. pub const RELEASES_PAGE: &str = "https://github.com/storytold/pdfcraft/releases"; -/// The latest published release. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Release { - /// Its version tag, such as `v0.2.0`. - pub version: String, - /// Its page on [`RELEASES_PAGE`], where the downloads are. - pub url: String, -} +/// Stands in for the network in tests. +#[cfg(not(target_arch = "wasm32"))] +pub type UpdateSource = CheckSource; -/// Asks for the latest release (blocking; it runs on its own thread). -pub type UpdateSource = Arc Result + Send + Sync>; - -/// Whether release `latest` (a tag such as `v0.2.0`) is newer than version `current` (`0.1.1`). -/// Pre-release and build suffixes are ignored; a version that doesn't parse is never newer. -pub fn is_newer(latest: &str, current: &str) -> bool { - matches!((parse(latest), parse(current)), (Some(l), Some(c)) if l > c) -} - -fn parse(v: &str) -> Option<(u64, u64, u64)> { - let v = v.trim().trim_start_matches(['v', 'V']); - let core = v.split(['-', '+']).next()?; - let mut parts = core.split('.'); - let mut next = |required: bool| match parts.next() { - Some(p) => p.parse::().ok(), - None if required => None, - None => Some(0), - }; - let version = (next(true)?, next(false)?, next(false)?); - parts.next().is_none().then_some(version) -} - -/// Where a check is. -#[derive(Default)] -pub(crate) enum Check { - #[default] - Idle, - #[cfg(not(target_arch = "wasm32"))] - Running(std::sync::mpsc::Receiver>), - Done(Result), -} - -#[derive(Default)] pub(crate) struct Updates { - pub(crate) check: Check, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, /// The Updates dialog is showing. pub(crate) open: bool, + /// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, +} + +impl Default for Updates { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")), + open: false, + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + } + } } impl PdfCraftApp { + /// Whether the app asks for a newer release on its first frame (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates.startup_check = on; + } + + /// Replaces the network with `source` (tests). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_update_source(&mut self, source: UpdateSource) { + self.updates.updater = Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")).with_source(source); + } + /// Help ▸ Check for updates: ask for the latest release and show the outcome. pub fn check_for_updates(&mut self) { - let Some(source) = self.update_source.clone() else { - self.open_url(RELEASES_PAGE); - return; - }; #[cfg(not(target_arch = "wasm32"))] { self.updates.open = true; - if matches!(self.updates.check, Check::Running(_)) { - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - let ctx = self.ctx.clone(); - std::thread::spawn(move || { - // The receiver may be gone (the app quit): nothing to report to then. - let _ = tx.send(source()); - if let Some(ctx) = ctx { - ctx.request_repaint(); - } - }); - self.updates.check = Check::Running(rx); + self.updates.check_requested = true; } #[cfg(target_arch = "wasm32")] { - let _ = source; self.open_url(RELEASES_PAGE); } } - /// Pick up a finished check (each frame). + /// Starts the checks and picks up their answers (each frame). pub(crate) fn poll_updates(&mut self) { #[cfg(not(target_arch = "wasm32"))] - if let Check::Running(rx) = &self.updates.check { - let result = match rx.try_recv() { - Ok(r) => r, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the update check stopped unexpectedly".into()), - }; - self.updates.check = Check::Done(result); + { + let Some(ctx) = self.ctx.clone() else { return }; + let updates = &mut self.updates; + if std::mem::take(&mut updates.check_requested) { + updates.updater.check_now(&ctx); + } else if updates.startup_check { + updates.updater.check_at_start(&ctx); + } + updates.updater.tick(&ctx); } } } -/// The Updates dialog. +/// The startup prompt, and the Updates dialog that answers Help ▸ Check for updates. pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = app.first_dirty().is_none(); + match app.updates.updater.show_if(ctx, clean) { + Action::None => {} + Action::OpenUrl(url) => app.open_url(&url), + Action::Close => { + app.request_quit(ctx); + } + } + // A requested check that found a release is answered by the prompt, not by this dialog. + if app.updates.open && !app.updates.check_requested && matches!(app.updates.updater.status(), Status::Idle) { + app.updates.open = false; + } + } if !app.updates.open { return; } let t = theme::Tokens::get(ctx); let current = env!("CARGO_PKG_VERSION"); let mut close = false; - let mut download: Option = None; let modal = crate::widgets::show_modal(ctx, "updates", |ui| { ui.set_width(420.0); ui.horizontal(|ui| { @@ -123,34 +113,25 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Check for updates")).font(theme::semibold(16.0))); }); ui.add_space(8.0); - match &app.updates.check { - Check::Idle => { - ui.label(tl!("No check has run yet.")); - } - #[cfg(not(target_arch = "wasm32"))] - Check::Running(_) => { + #[cfg(not(target_arch = "wasm32"))] + match app.updates.updater.status() { + Status::Idle => { ui.horizontal(|ui| { ui.spinner(); ui.label(tl!("Checking for a newer version…")); }); } - Check::Done(Ok(r)) if is_newer(&r.version, current) => { - let version = r.version.trim_start_matches(['v', 'V']); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("PdfCraft {v} is available."), &[("v", version)])).strong()); - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("You have version {c}. Download the new version from its release page."), - &[("c", current)], - )) - .color(t.text_muted), - ); - download = Some(r.url.clone()); + Status::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tl!("Checking for a newer version…")); + }); } - Check::Done(Ok(_)) => { + Status::UpToDate => { ui.label(crate::i18n::fmt(tl!("PdfCraft {c} is up to date."), &[("c", current)])); } - Check::Done(Err(e)) => { - ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", &e.to_string())])); + Status::Failed(e) => { + ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", e)])); ui.label( egui::RichText::new(crate::i18n::fmt( tl!("You have version {c}. All releases are listed at {page}."), @@ -160,32 +141,21 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ); } } + #[cfg(target_arch = "wasm32")] + let _ = (&t, current); ui.add_space(10.0); - ui.label( - egui::RichText::new(tl!("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.")) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if let Some(url) = download.take() { - let get = widgets::pill_button(ui, tl!("Download"), true).clicked(); - let later = widgets::pill_button(ui, tl!("Later"), false).clicked(); - close = get || later; - download = get.then_some(url); - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } }); }); if modal.should_close() { close = true; - download = None; } if close { app.updates.open = false; - if let Some(url) = download { - app.open_url(&url); - } + #[cfg(not(target_arch = "wasm32"))] + app.updates.updater.dismiss_status(); } } diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index a8ecb31c56..d2eaa7c660 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -5,18 +5,28 @@ use std::sync::Arc; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; -use pdfcraft_ui_egui::updates::{Release, UpdateSource, is_newer}; +use pdfcraft_ui_egui::updates::UpdateSource; -fn source(answer: Result<&str, &str>) -> UpdateSource { - let answer = answer.map(str::to_string).map_err(str::to_string); - Arc::new(move || answer.clone().map(|v| Release { url: format!("https://github.com/storytold/pdfcraft/releases/tag/{v}"), version: v })) +fn release(tag: &str) -> craft_update::Release { + craft_update::Release { + tag: tag.to_string(), + version: craft_update::parse_version(tag), + html_url: format!("https://github.com/storytold/pdfcraft/releases/tag/{tag}"), + ..Default::default() + } } -fn harness(answer: Result<&str, &str>) -> Harness<'static, PdfCraftApp> { +/// `Ok(Some(tag))` is a newer release, `Ok(None)` is up to date. +fn source(answer: Result, &str>) -> UpdateSource { + let answer = answer.map(|tag| tag.map(release)).map_err(str::to_string); + Arc::new(move || answer.clone()) +} + +fn harness(answer: Result, &str>) -> Harness<'static, PdfCraftApp> { Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.update_source = Some(source(answer)); + app.set_update_source(source(answer)); app }) } @@ -37,34 +47,21 @@ fn settle(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(2); } -#[test] -fn versions_compare_by_number() { - assert!(is_newer("v0.2.0", "0.1.1")); - assert!(is_newer("v0.1.10", "0.1.9")); - assert!(is_newer("1", "0.9.9")); - assert!(!is_newer("v0.1.1", "0.1.1")); - assert!(!is_newer("v0.1.0", "0.1.1")); - assert!(!is_newer("v0.1.1-beta.2", "0.1.1"), "suffixes are ignored"); - assert!(!is_newer("nightly", "0.1.1"), "a tag that isn't a version is never newer"); - assert!(!is_newer("v1.2.3.4", "0.1.1")); - assert!(!is_newer("v99999999999999999999.0.0", "0.1.1"), "out of range"); -} - #[test] fn a_newer_release_is_offered_for_download() { - let mut h = harness(Ok("v99.0.0")); + let mut h = harness(Ok(Some("v99.0.0"))); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("PdfCraft 99.0.0 is available"); - h.get_by_label("Download"); - h.get_by_label("Later").click(); + h.get_by_label("DOWNLOAD"); + h.get_by_label("LATER").click(); h.run_steps(3); assert!(h.query_by_label_contains("is available").is_none(), "Later closes the dialog"); } #[test] fn an_up_to_date_or_failed_check_says_so() { - let mut h = harness(Ok(concat!("v", env!("CARGO_PKG_VERSION")))); + let mut h = harness(Ok(None)); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("is up to date"); @@ -78,7 +75,7 @@ fn an_up_to_date_or_failed_check_says_so() { #[test] fn nothing_is_asked_until_the_user_checks() { - // No check at start (the owner's decision): the source is only called from the command. + // The check at start is opt-in (the desktop app turns it on): the source is only called from the command. let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = calls.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { @@ -87,9 +84,9 @@ fn nothing_is_asked_until_the_user_checks() { // Settings from a build that had the startup option are ignored. app.restore(r#"{"check_updates_at_start": true}"#); let counted = counted.clone(); - app.update_source = Some(Arc::new(move || { + app.set_update_source(Arc::new(move || { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Release { version: "v99.0.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v99.0.0".into() }) + Ok(Some(release("v99.0.0"))) })); app }); @@ -101,3 +98,23 @@ fn nothing_is_asked_until_the_user_checks() { assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); h.get_by_label_contains("PdfCraft 99.0.0 is available"); } + +#[test] +fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_update_source(source(Ok(Some("v99.0.0")))); + app.set_startup_update_check(true); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.query_by_label_contains("is available").is_none() { + h.run_steps(2); + assert!(std::time::Instant::now() < deadline, "timed out waiting for the startup prompt"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.get_by_label("LATER").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("is available").is_none(), "Later closes the prompt"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8d2f3ecf67..cbbb96da68 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] -notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved" [[feature]] id = "view.single-key-accelerators" From 833cd63c8a0aec826595640b356b3213355957bd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:14:06 -0400 Subject: [PATCH 07/33] proposal(redact): prevent retention of confidential vector paths behind clipping (F-008) (#4577) Partial vector redaction previously hid covered areas with clipping (`clip_out`, `W/W*`) while retaining the complete original drawing instructions and painting operators in the content stream. This allowed recipients to recover confidential outlined text or other vector drawings from supposedly redacted documents. This change removes vector paths that intersect redaction marks completely and refuses partial clip retention, ensuring no confidential geometry is preserved behind clipping. Report counts are updated to record removed paths. --- crates/redact/src/interp.rs | 33 +++++---------------------------- crates/redact/src/tests.rs | 30 ++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ffd59a26f6..bd9da5215f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,37 +884,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } let b = bbox.unwrap_or_default(); changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. ops.extend(body); ops.push(Op::new("n", vec![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. continue; } if !path.is_empty() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8b94612145..c072d19db7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); - assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +#[test] +fn partially_covered_vector_paths_do_not_leak_drawing_commands() { + // A vector path with detailed drawing commands (e.g. outlined text or confidential geometry) + // that is only partially covered by a redaction mark must be completely removed, not retained + // behind a clipping path (F-008). + let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S"; + let mut doc = one_page(src, "", vec![]); + // Redaction mark covers only part of the triangle (100..200, 100..150): + // mark is [120, 120, 160, 160], partially intersecting the triangle. + // Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25]. + mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "both partially intersected paths removed"); + assert_eq!(r.paths_clipped, 0, "no paths retained via clipping"); + + let c = content(&doc, 0); + // Neither the drawing operators nor the coordinates should leak in the content stream. + assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}"); + assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}"); + // Unrelated non-intersecting path remains untouched. + assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}"); +} + /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cbbb96da68..5e1c4a7e75 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5793,7 +5793,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"] -notes = "covered paths removed, others clipped; shadings clipped" +notes = "intersecting paths removed to avoid leaking geometry behind clipping; shadings clipped" [[feature]] id = "protect.redact-annots-fields" From cda2d019e3fe19a1229961f91dde260d4a76a599 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:06 -0400 Subject: [PATCH 08/33] build: pin craft-update to craft-libs main (495e597) (#4721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 947dfb0c82..1acd1ff348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 39d11d1eaf..4d1d5e5f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } From 83ba28680f6c780ed7ae553f25555765e4802aeb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:54:18 -0400 Subject: [PATCH 09/33] build: pin craft-update to craft-libs main (495e597) (#4728) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1acd1ff348..fd3e1930fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3352,7 +3352,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3913,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5051,7 +5051,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5601,7 +5601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5671,7 +5671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6336,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6944,7 +6944,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7670,7 +7670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] From ecf62314160a2700d66ddd6c29def50346b07862 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:39 +0100 Subject: [PATCH 10/33] refactor(engine): move undo/redo state and labels to history.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/history.rs | 233 +++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 231 +--------------------------------- 2 files changed, 236 insertions(+), 228 deletions(-) create mode 100644 crates/engine/src/history.rs diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000000..086d98a6ab --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,233 @@ +//! Undo/redo: the editing state of a document, its snapshots and their labels. + +use std::sync::Arc; + +use crate::{AppearanceCache, Edit, EditError, Shape}; + +/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. +pub(crate) const MAX_UNDO: usize = 100; + +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Keys { + /// What the renderer and inspector open the working file with (the user password). + pub(crate) render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + pub(crate) reopen: Option, +} + +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Document-information entries; page content and appearances are unchanged. + Metadata, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, + /// Only page content streams, resources and added content/marks: the page tree, + /// annotations, form fields, links, outline and fonts are unchanged, so the full + /// re-inspection is skipped (seconds on very large files). + Content, +} + +/// Edits during which field scripts run. +pub(crate) fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + +pub(crate) fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + // Page content, resources and added content/marks only: the page tree, annotations, + // fields, links and document metadata are untouched (see `refresh_content`). + Edit::MoveObjects { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } => Scope::Content, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); + +/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). +#[derive(Clone)] +pub(crate) struct Editor { + pub(crate) cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + pub(crate) saved: Arc>, + /// Passwords that open [`Self::saved`]. + pub(crate) saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + pub(crate) unsaved_file: bool, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) keys: Keys, + /// Display appearances built for annotations without any, reused while no annotation edit + /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh + /// that may change annotations; see [`AppearanceCache`]. + pub(crate) appearances: AppearanceCache, + /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused + /// for each widget while the objects it was drawn from are unchanged; see + /// [`pdfcraft_forms::FieldAppearanceCache`]. + pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache, + /// The text blocks last computed for one page, reused while the document is unchanged. + pub(crate) text_blocks: TextBlocksCache, +} + +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. +#[derive(Default)] +pub(crate) struct TextBlocksCache(std::sync::Mutex>); + +/// The document state, the page (0-based) and its text blocks. +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); + +impl Clone for TextBlocksCache { + fn clone(&self) -> Self { + // A poisoned cache only loses the cached blocks. + Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) + } +} + +impl Editor { + /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. + pub(crate) fn text_blocks(&self, page: usize) -> Result>, EditError> { + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() + && *p == page + && state.matches(&self.cos) + { + return Ok(blocks.clone()); + } + let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); + } + Ok(blocks) + } +} + +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The passwords after `edit`, if it changes them. +pub(crate) fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 224318f1d7..90c698dfe3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ mod copy_image; pub mod dates; pub mod export; pub mod field_table; +mod history; pub mod js; pub mod links; pub mod ocr; @@ -226,6 +227,8 @@ fn record_link_scan() { }); } +pub use history::Label; +use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; @@ -233,168 +236,10 @@ use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderC #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); -/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. -const MAX_UNDO: usize = 100; - /// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -/// The passwords that go with one state of a document (protection can change them). -#[derive(Clone, Debug, Default, PartialEq)] -struct Keys { - /// What the renderer and inspector open the working file with (the user password). - render: Option, - /// What the editor re-opens a saved file with: the strongest password known (owner if any). - reopen: Option, -} - -/// What an edit can change, and so what the view data must be rebuilt from. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Scope { - /// Anything: re-inspect the whole document. - Full, - /// Document-information entries; page content and appearances are unchanged. - Metadata, - /// Only comments: re-read the comment list from the object graph. - Comments, - /// Only form field values (and their widget appearances). - Form, - /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. - Links, - /// Only page content streams, resources and added content/marks: the page tree, - /// annotations, form fields, links, outline and fonts are unchanged, so the full - /// re-inspection is skipped (seconds on very large files). - Content, -} - -/// Edits during which field scripts run. -fn uses_scripts(edit: &Edit) -> bool { - match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, - Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), - _ => false, - } -} - -fn scope_of(edit: &Edit) -> Scope { - match edit { - Edit::SetInfo { .. } => Scope::Metadata, - // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, - // A file attachment also changes the Attachments list. - Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::LockAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, - // Page content, resources and added content/marks only: the page tree, annotations, - // fields, links and document metadata are untouched (see `refresh_content`). - Edit::MoveObjects { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::EditPageImage { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, - Edit::Batch { edits, .. } => { - let mut scopes = edits.iter().map(scope_of); - let first = scopes.next().unwrap_or(Scope::Full); - if scopes.all(|s| s == first) { first } else { Scope::Full } - } - _ => Scope::Full, - } -} - -/// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); - -/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). -#[derive(Clone)] -struct Editor { - cos: pdfcraft_cos::Document, - /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. - /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. - saved: Arc>, - /// Passwords that open [`Self::saved`]. - saved_keys: Keys, - /// [`Self::saved`] was never written where the document belongs: a new document (combine, - /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever - /// undo brings back, or closing it would lose the whole document without asking. - unsaved_file: bool, - undo: Vec, - redo: Vec, - keys: Keys, - /// Display appearances built for annotations without any, reused while no annotation edit - /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh - /// that may change annotations; see [`AppearanceCache`]. - appearances: AppearanceCache, - /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused - /// for each widget while the objects it was drawn from are unchanged; see - /// [`pdfcraft_forms::FieldAppearanceCache`]. - field_appearances: pdfcraft_forms::FieldAppearanceCache, - /// The text blocks last computed for one page, reused while the document is unchanged. - text_blocks: TextBlocksCache, -} - -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read -/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not -/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns -/// with undo, so a stale entry never matches, also after the document has been edited. -#[derive(Default)] -struct TextBlocksCache(std::sync::Mutex>); - -/// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); - -impl Clone for TextBlocksCache { - fn clone(&self) -> Self { - // A poisoned cache only loses the cached blocks. - Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) - } -} - -impl Editor { - /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. - fn text_blocks(&self, page: usize) -> Result>, EditError> { - if let Ok(cache) = self.text_blocks.0.lock() - && let Some((state, p, blocks)) = cache.as_ref() - && *p == page - && state.matches(&self.cos) - { - return Ok(blocks.clone()); - } - let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Ok(mut cache) = self.text_blocks.0.lock() { - *cache = Some((self.cos.read_state(), page, blocks.clone())); - } - Ok(blocks) - } -} - pub struct Document { pub id: DocId, pub name: String, @@ -1918,64 +1763,6 @@ pub enum Edit { }, } -/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: -/// a whole English wording, and the one value the document itself supplies for the wording's hole -/// (a file name, a field name). An interface in another language looks the wording up and says the -/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so -/// the two cannot drift apart. -#[derive(Clone, Debug, PartialEq)] -pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. - pub wording: &'static str, - /// What the document supplies, when the wording has a hole for it. - pub value: Option, - text: String, -} - -impl Label { - /// A wording with nothing to fill in. - pub fn of(wording: &'static str) -> Self { - Self { wording, value: None, text: wording.to_string() } - } - - /// A wording whose hole the document fills in. - pub fn with(wording: &'static str, value: impl Into) -> Self { - let mut label = Self { wording, value: Some(value.into()), text: String::new() }; - label.text = label.said(wording); - label - } - - /// The label read with this wording: the interface's translation of [`wording`](Self::wording), - /// or the English wording itself. What the document supplies goes in as it stands and is not - /// read again, so a name with braces in it stays whole. - pub fn said(&self, wording: &str) -> String { - let Some(value) = &self.value else { return wording.to_owned() }; - // `{` and `}` are ASCII, so these are char boundaries. - match (wording.find('{'), wording.find('}')) { - (Some(open), Some(close)) if open < close => { - let mut said = String::with_capacity(wording.len() + value.len()); - said.push_str(&wording[..open]); - said.push_str(value); - said.push_str(&wording[close + 1..]); - said - } - // A translation that lost its hole is said as it stands rather than losing the value. - _ => wording.to_owned(), - } - } - - /// The label as English reads it. - pub fn as_str(&self) -> &str { - &self.text - } -} - -impl std::fmt::Display for Label { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.text) - } -} - /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see /// [`BATCH_LABELS`]). @@ -3051,18 +2838,6 @@ fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) } -/// The passwords after `edit`, if it changes them. -fn keys_after(edit: &Edit) -> Option { - match edit { - Edit::Protect(p) => { - Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) - } - Edit::RemoveProtection => Some(Keys::default()), - Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), - _ => None, - } -} - /// The recovered user password of an R2–R4 file as text for the renderer, which tries a password /// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords /// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as From 7e72b583edbfc942802b79aff453a3f8193da920 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:53 +0100 Subject: [PATCH 11/33] refactor(engine): move EditError and SourceProblem to error.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/error.rs | 80 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 79 +------------------------------------ 2 files changed, 82 insertions(+), 77 deletions(-) create mode 100644 crates/engine/src/error.rs diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 0000000000..5ee915fcdf --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,80 @@ +//! The errors editing and combining report. + +use crate::measure; + +/// Why a file can't be a source for Combine Files (or Insert / Replace pages). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SourceProblem { + #[error("it is password-protected")] + Password, + #[error("the password is wrong")] + WrongPassword, + #[error("its security settings don't allow copying pages")] + NotPermitted, + #[error("{0}")] + Unreadable(String), +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EditError { + #[error("no such document")] + NoDocument, + #[error("this document can't be edited: {0}")] + ReadOnly(String), + #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] + NotPermitted(&'static str), + #[error("{0}")] + Organize(#[from] pdfcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] pdfcraft_organize::OutlineError), + #[error("{0}")] + Comment(#[from] pdfcraft_annot::AnnotError), + #[error(transparent)] + Measure(#[from] measure::MeasureError), + #[error("{0}")] + Protection(String), + #[error("the password is incorrect")] + WrongPassword, + #[error("{0}")] + Form(#[from] pdfcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] pdfcraft_redact::RedactError), + #[error("{0}")] + Print(String), + #[error("{0}")] + Accessibility(String), + #[error(transparent)] + Data(#[from] pdfcraft_xfdf::DataError), + #[error("{0}")] + Edit(#[from] pdfcraft_edit::EditError), + #[error("{0}")] + Create(#[from] pdfcraft_create::CreateError), + #[error("the edited document could not be written: {0}")] + Write(String), + #[error("the edited document could not be reopened: {0}")] + Reopen(String), + #[error("couldn't use {0}")] + Source(String), + #[error("nothing to undo")] + NothingToUndo, + #[error("nothing to redo")] + NothingToRedo, + #[error("{0}")] + Sign(String), + #[error("{0}")] + Optimize(String), + #[error("cancelled")] + Cancelled, + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), + #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] + Signed, + #[error("{0}")] + Invalid(String), +} + +impl From for EditError { + fn from(e: pdfcraft_sign::SignError) -> Self { + EditError::Sign(e.to_string()) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90c698dfe3..d34decd658 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod error; pub mod export; pub mod field_table; mod history; @@ -227,6 +228,7 @@ fn record_link_scan() { }); } +pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; @@ -2889,19 +2891,6 @@ fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } -/// Why a file can't be a source for Combine Files (or Insert / Replace pages). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum SourceProblem { - #[error("it is password-protected")] - Password, - #[error("the password is wrong")] - WrongPassword, - #[error("its security settings don't allow copying pages")] - NotPermitted, - #[error("{0}")] - Unreadable(String), -} - fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), @@ -2933,70 +2922,6 @@ fn plural(one: &'static str, many: &'static str, n: usize) -> Label { Label::of(if n == 1 { one } else { many }) } -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum EditError { - #[error("no such document")] - NoDocument, - #[error("this document can't be edited: {0}")] - ReadOnly(String), - #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] - NotPermitted(&'static str), - #[error("{0}")] - Organize(#[from] pdfcraft_organize::OrganizeError), - #[error("{0}")] - Bookmark(#[from] pdfcraft_organize::OutlineError), - #[error("{0}")] - Comment(#[from] pdfcraft_annot::AnnotError), - #[error(transparent)] - Measure(#[from] measure::MeasureError), - #[error("{0}")] - Protection(String), - #[error("the password is incorrect")] - WrongPassword, - #[error("{0}")] - Form(#[from] pdfcraft_forms::FormError), - #[error(transparent)] - Redact(#[from] pdfcraft_redact::RedactError), - #[error("{0}")] - Print(String), - #[error("{0}")] - Accessibility(String), - #[error(transparent)] - Data(#[from] pdfcraft_xfdf::DataError), - #[error("{0}")] - Edit(#[from] pdfcraft_edit::EditError), - #[error("{0}")] - Create(#[from] pdfcraft_create::CreateError), - #[error("the edited document could not be written: {0}")] - Write(String), - #[error("the edited document could not be reopened: {0}")] - Reopen(String), - #[error("couldn't use {0}")] - Source(String), - #[error("nothing to undo")] - NothingToUndo, - #[error("nothing to redo")] - NothingToRedo, - #[error("{0}")] - Sign(String), - #[error("{0}")] - Optimize(String), - #[error("cancelled")] - Cancelled, - #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] - SignedRewrite(String), - #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] - Signed, - #[error("{0}")] - Invalid(String), -} - -impl From for EditError { - fn from(e: pdfcraft_sign::SignError) -> Self { - EditError::Sign(e.to_string()) - } -} - #[derive(Default)] pub struct Session { docs: Vec, From 58e7e367172b08f6951cac4cf7b10010e8c708fd Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:20:51 +0100 Subject: [PATCH 12/33] refactor(engine): move the Edit enum, its labels, permission check and runner to edit.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 1577 ++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1590 +------------------------------------ 2 files changed, 1590 insertions(+), 1577 deletions(-) create mode 100644 crates/engine/src/edit.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 0000000000..a29e4a870b --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,1577 @@ +//! The edits a document takes: what each is called, whether its permissions allow it, and running it. + +use std::sync::Arc; + +use crate::{ + AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, + Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; + +/// Edits that can be applied to a document. Page indices are 0-based. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + RotatePages { + pages: Vec, + degrees: i64, + }, + DeletePages { + pages: Vec, + }, + MovePages { + pages: Vec, + to: usize, + }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, + InsertBlankPage { + at: usize, + width: f64, + height: f64, + }, + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. + DuplicatePages { + pages: Vec, + at: Option, + }, + /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the + /// pages' links, comments and bookmarks stay. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, + SetInfo { + key: String, + value: String, + }, + /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. + InsertPagesFrom { + name: String, + bytes: Arc>, + pages: Option>, + 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). + /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and + /// either may be `None` to keep the reader's position that way (both `None` is the page top). + /// A coordinate that is not finite fails the edit. + AddBookmark { + parent: Vec, + index: usize, + title: String, + page: usize, + left: Option, + top: Option, + }, + RenameBookmark { + path: Vec, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, + /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first + /// "Untitled" bookmark (New Bookmarks from Structure). + BookmarksFromStructure, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: pdfcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, + /// Add a calibrated distance, perimeter or area annotation. + AddMeasurement(measure::NewMeasurement), + /// Store a drawing scale for a rectangular viewport (PDF user space). + SetMeasurementScale { + page: usize, + bbox: [f64; 4], + name: String, + scale: measure::Scale, + }, + /// Add a comment (sticky note, highlight, shape, drawing, text box…). + AddAnnotation(NewAnnotation), + /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size + /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). + /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, + /// and centred in it (#4077). + AddCustomStamp { + page: usize, + rect: [f64; 4], + name: String, + file: MarkFile, + author: String, + }, + /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. + DeleteAnnotation { + page: usize, + index: usize, + }, + SetAnnotationContents { + page: usize, + index: usize, + text: String, + }, + ReplyToAnnotation { + page: usize, + index: usize, + text: String, + author: String, + }, + /// Acrobat's "Set status" (a state reply by `author`). + SetAnnotationStatus { + page: usize, + index: usize, + state: ReviewState, + author: String, + }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, + /// Replace Text: strike out `quads` and add a grouped caret holding `text`. + ReplaceText { + page: usize, + quads: Vec<[f64; 8]>, + text: String, + author: String, + strike: Style, + caret: Style, + }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, + MoveAnnotation { + page: usize, + index: usize, + dx: f64, + dy: f64, + }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, + /// Resize a rectangle, oval or text box. + ResizeAnnotation { + page: usize, + index: usize, + rect: [f64; 4], + }, + StyleAnnotation { + page: usize, + index: usize, + color: Option, + opacity: Option, + width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, + }, + /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). + FillAnnotation { + page: usize, + index: usize, + fill: Option, + }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: Box, + }, + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, + DeleteField { + name: String, + }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, + /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is + /// the figure element's object number (from `Document::figures`). + SetAltText { + figure: u32, + alt: Option, + }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, + /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. + MarkDecorative { + figure: u32, + }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, + /// forbidden actions, annotation flags, …). + ConvertPdfA { + level: pdfcraft_preflight::Level, + }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, + /// What a script (button or console) changed in form fields: values, read-only, required + /// and visibility. + ApplyScriptChanges { + changes: Vec, + }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, + /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the + /// image (from [`ocr::OcrJob::run`]). + AddOcrText { + page: usize, + words: Vec, + }, + /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on + /// `page`, in its own font when it can show it, else in Helvetica. + EditTextLine { + page: usize, + line: usize, + text: String, + }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: pdfcraft_edit::BlockStyle, + }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, + /// Order tabs manually: move a field one place earlier or later on its page. + MoveInTabOrder { + name: String, + earlier: bool, + }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`]). The form recalculates. + SetCalculationOrder { + names: Vec, + }, + /// Add a header and footer (with `replace`, existing ones on those pages go first). + AddHeaderFooter { + pages: Vec, + settings: HeaderFooter, + replace: bool, + }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + file: Option, + }, + /// `file`: a picture from a file instead of a colour. + AddBackground { + pages: Vec, + settings: Background, + replace: bool, + file: Option, + }, + /// Remove every mark of a kind from every page. + RemoveMarks { + kind: MarkKind, + }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + ImportData { + name: String, + bytes: Arc>, + }, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, + /// Several edits applied as one undoable step (all or nothing). + Batch { + label: String, + edits: Vec, + }, +} + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + 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(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d34decd658..0926c3ed28 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod edit; mod error; pub mod export; pub mod field_table; @@ -228,6 +229,8 @@ fn record_link_scan() { }); } +pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; +use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; @@ -1228,1574 +1231,13 @@ impl Protection { } } -/// Edits that can be applied to a document. Page indices are 0-based. -#[derive(Clone, Debug, PartialEq)] -pub enum Edit { - RotatePages { - pages: Vec, - degrees: i64, - }, - DeletePages { - pages: Vec, - }, - MovePages { - pages: Vec, - to: usize, - }, - /// `pages` reversed among the positions they occupy. - ReversePages { - pages: Vec, - }, - InsertBlankPage { - at: usize, - width: f64, - height: f64, - }, - /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after - /// the last of them when `at` is `None`. - DuplicatePages { - pages: Vec, - at: Option, - }, - /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the - /// pages' links, comments and bookmarks stay. - ReplacePages { - pages: Vec, - name: String, - bytes: Arc>, - src_pages: Vec, - }, - /// Set Page Boxes / Crop: set (or reset) one box on `pages`. - SetPageBox { - pages: Vec, - which: PageBox, - spec: BoxSpec, - }, - /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), - /// Organize ▸ Page Transitions. - SetTransition { - pages: Vec, - spec: TransitionSpec, - }, - SetInfo { - key: String, - value: String, - }, - /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. - InsertPagesFrom { - name: String, - bytes: Arc>, - pages: Option>, - 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). - /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and - /// either may be `None` to keep the reader's position that way (both `None` is the page top). - /// A coordinate that is not finite fails the edit. - AddBookmark { - parent: Vec, - index: usize, - title: String, - page: usize, - left: Option, - top: Option, - }, - RenameBookmark { - path: Vec, - title: String, - }, - /// Delete a bookmark and the bookmarks under it. - DeleteBookmark { - path: Vec, - }, - /// Move a bookmark to child `index` of `to_parent` (indices after removing it). - MoveBookmark { - from: Vec, - to_parent: Vec, - index: usize, - }, - /// Point a bookmark at another page. - SetBookmarkPage { - path: Vec, - page: usize, - }, - /// Colour a bookmark's title (`/C`); `None` or black is the default black. - SetBookmarkColor { - path: Vec, - color: Option<[f64; 3]>, - }, - /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. - SetBookmarkStyle { - path: Vec, - bold: Option, - italic: Option, - }, - /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first - /// "Untitled" bookmark (New Bookmarks from Structure). - BookmarksFromStructure, - /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. - NumberPages { - from: usize, - to: usize, - style: pdfcraft_organize::LabelStyle, - prefix: String, - first: u32, - }, - /// Add a calibrated distance, perimeter or area annotation. - AddMeasurement(measure::NewMeasurement), - /// Store a drawing scale for a rectangular viewport (PDF user space). - SetMeasurementScale { - page: usize, - bbox: [f64; 4], - name: String, - scale: measure::Scale, - }, - /// Add a comment (sticky note, highlight, shape, drawing, text box…). - AddAnnotation(NewAnnotation), - /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size - /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). - /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, - /// and centred in it (#4077). - AddCustomStamp { - page: usize, - rect: [f64; 4], - name: String, - file: MarkFile, - author: String, - }, - /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. - DeleteAnnotation { - page: usize, - index: usize, - }, - SetAnnotationContents { - page: usize, - index: usize, - text: String, - }, - ReplyToAnnotation { - page: usize, - index: usize, - text: String, - author: String, - }, - /// Acrobat's "Set status" (a state reply by `author`). - SetAnnotationStatus { - page: usize, - index: usize, - state: ReviewState, - author: String, - }, - /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). - EraseInk { - page: usize, - index: usize, - path: Vec<[f64; 2]>, - radius: f64, - }, - /// Replace Text: strike out `quads` and add a grouped caret holding `text`. - ReplaceText { - page: usize, - quads: Vec<[f64; 8]>, - text: String, - author: String, - strike: Style, - caret: Style, - }, - /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). - MarkAnnotation { - page: usize, - index: usize, - marked: bool, - author: String, - }, - /// Properties ▸ Locked. - LockAnnotation { - page: usize, - index: usize, - locked: bool, - }, - MoveAnnotation { - page: usize, - index: usize, - dx: f64, - dy: f64, - }, - /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's - /// two ends, the others' corners). - ReshapeAnnotation { - page: usize, - index: usize, - points: Vec<[f64; 2]>, - }, - /// Resize a rectangle, oval or text box. - ResizeAnnotation { - page: usize, - index: usize, - rect: [f64; 4], - }, - StyleAnnotation { - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. - endings: Option>, - }, - /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). - FillAnnotation { - page: usize, - index: usize, - fill: Option, - }, - /// Change the font size (pt) of a text box, typewriter text or callout. - FontSizeAnnotation { - page: usize, - index: usize, - size: f64, - }, - /// Comment properties ▸ General / note icon. - SetAnnotationInfo { - page: usize, - index: usize, - author: Option, - subject: Option, - icon: Option, - }, - /// Fill in a form field. - SetFieldValue { - name: String, - value: FieldValue, - }, - /// Clear form: the named fields (all when `None`) back to their defaults. - ResetForm { - names: Option>, - }, - /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to - /// Acrobat's next free name ("Text1", "Check Box2", …). - AddField { - page: usize, - rect: [f64; 4], - kind: NewField, - name: Option, - }, - /// Field Properties ▸ General / Options. - SetFieldProps { - name: String, - props: Box, - }, - /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). - SetFieldImage { - name: String, - image: Arc>, - }, - /// Duplicate a field onto other pages (same field, shared value). - DuplicateField { - name: String, - pages: Vec, - }, - DeleteField { - name: String, - }, - /// Page properties ▸ Tab order, for pages (0-based). - SetTabOrder { - pages: Vec, - order: TabOrder, - }, - /// Document Properties ▸ Initial View (and the language and binding). - SetInitialView(Box), - /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under - /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. - AddAttachment { - name: String, - bytes: Vec, - description: Option, - mime: Option, - }, - /// Remove the document-level attachment whose name-tree key is `key`. - DeleteAttachment { - key: String, - }, - /// Build a tagged structure tree for an untagged, text-only document. - AutoTagText, - /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is - /// the figure element's object number (from `Document::figures`). - SetAltText { - figure: u32, - alt: Option, - }, - /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. - AutoTagFormFields, - /// Change one property on a reachable indirect structure tag. - SetTagProperty { - object: pdfcraft_cos::ObjRef, - property: a11y::TagProperty, - value: Option, - }, - /// Move a structure tag before or after a direct sibling in reading order. - MoveTag { - object: pdfcraft_cos::ObjRef, - anchor: pdfcraft_cos::ObjRef, - placement: a11y::TagPlacement, - }, - /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. - MarkDecorative { - figure: u32, - }, - /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. - MarkTagArtifact { - object: pdfcraft_cos::ObjRef, - }, - /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level - /// script `name`. - SetDocumentScript { - name: String, - script: Option, - }, - /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` - /// (keystroke, format, validate, calculate or mouse_up). - SetFieldScript { - name: String, - event: String, - script: Option, - }, - /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, - /// forbidden actions, annotation flags, …). - ConvertPdfA { - level: pdfcraft_preflight::Level, - }, - /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). - RemoveStandardId { - standard: Standard, - }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. - ApplyScriptChanges { - changes: Vec, - }, - /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the - /// script changes (values, rows, visibility) is applied and the form laid out again. - XfaEvent { - som: String, - activity: String, - }, - /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). - AddOcrText { - page: usize, - words: Vec, - }, - /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on - /// `page`, in its own font when it can show it, else in Helvetica. - EditTextLine { - page: usize, - line: usize, - text: String, - }, - /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). - EditPageImage { - page: usize, - index: usize, - change: ImageEdit, - }, - /// Move mixed existing and added objects together. References resolve before any change; - /// offset is a user-space vector. No retyping, reflow or font substitution is involved. - MoveObjects { - page: usize, - objects: Vec, - offset: [f64; 2], - }, - /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), - /// rewrapped to the box's width. - EditTextBlock { - page: usize, - block: usize, - text: String, - /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. - style: pdfcraft_edit::BlockStyle, - }, - /// An explicit open-licensed full font program, completing subsets or selected by a user. - RegisterTextFont { - page: usize, - bytes: Arc>, - }, - /// Order tabs manually: move a field one place earlier or later on its page. - MoveInTabOrder { - name: String, - earlier: bool, - }, - /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. - SetCalculationOrder { - names: Vec, - }, - /// Add a header and footer (with `replace`, existing ones on those pages go first). - AddHeaderFooter { - pages: Vec, - settings: HeaderFooter, - replace: bool, - }, - /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. - AddWatermark { - pages: Vec, - settings: Watermark, - replace: bool, - file: Option, - }, - /// `file`: a picture from a file instead of a colour. - AddBackground { - pages: Vec, - settings: Background, - replace: bool, - file: Option, - }, - /// Remove every mark of a kind from every page. - RemoveMarks { - kind: MarkKind, - }, - /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). - AddText { - page: usize, - text: AddedText, - }, - /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at - /// its natural size centred on the page (shrunk to fit). - AddImage { - page: usize, - rect: Option<[f64; 4]>, - name: String, - bytes: Arc>, - }, - /// Move, resize, retype or reformat an added item (`index` among the page's added items). - UpdateContent { - page: usize, - index: usize, - content: AddedContent, - }, - DeleteContent { - page: usize, - index: usize, - }, - /// Replace an added image with another file (keeping its box and transforms). - ReplaceImage { - page: usize, - index: usize, - name: String, - bytes: Arc>, - }, - /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its - /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused - /// while it is still that picture in this document, else one an added image here already draws - /// it from, else the file is embedded. - PasteContent { - page: usize, - content: AddedContent, - file: Option<(String, Arc>)>, - }, - /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. - ApplyRedactions { - pages: Option>, - }, - /// Remove redaction marks without applying them. - ClearRedactions, - /// Edit a PDF ▸ Link: a new link over `rect` (user space). - AddLink { - page: usize, - rect: [f64; 4], - action: LinkAction, - style: LinkStyle, - }, - /// Link Properties (`index` in the page's `/Annots`). - SetLink { - page: usize, - index: usize, - rect: Option<[f64; 4]>, - action: Option, - style: Option, - }, - DeleteLink { - page: usize, - index: usize, - }, - /// Remove all links (on `pages`, or everywhere). - RemoveLinks { - pages: Option>, - }, - /// Create links from URLs in the text: (page, line rects in user space, URI). - AddLinks { - links: Vec<(usize, Vec<[f64; 4]>, String)>, - style: LinkStyle, - }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). - ImportData { - name: String, - bytes: Arc>, - }, - /// Remove Hidden Information: the chosen categories. - RemoveHidden { - which: Vec, - }, - /// Sanitize Document: every category, then a full rewrite on save. - Sanitize, - /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and - /// PdfCraft's private keys go, the pages as laid out and the fields with their values - /// stay, so every viewer shows the same form and none lays it out again. Its scripts no - /// longer run. - XfaToStatic, - /// Flatten comments and/or form fields on every page into page content. - Flatten { - comments: bool, - fields: bool, - }, - /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. - /// A no-op (no undo step) when the document has none. - FlattenFillSign, - /// Protect with passwords and permissions (written by the next save, which is a full rewrite). - Protect(Protection), - /// Remove password security (needs the owner password). - RemoveProtection, - /// Several edits applied as one undoable step (all or nothing). - Batch { - label: String, - edits: Vec, - }, -} - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), - Edit::ReversePages { .. } => Label::of("Reverse pages"), - Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), - Edit::SetPageBox { .. } => Label::of("Set page boxes"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - /// Opened as owner, or nothing is restricted (no permissions password was set): security may /// be changed, as in Acrobat. -fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 p.owner || p.bits & ALL == ALL } -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - xfa: Option>, - xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - xfa_ran_away: bool, - /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). - xfa_time_scale: u32, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pages: usize, - date: Option, - /// Today in local time, for date tokens. - 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)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - redaction_overlays: Vec, -} - -impl EditCtx { - fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_time_scale: 1, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, - Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; - pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - 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(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page, left, top } => { - pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - // As after a script edit: the values the new order gives show at once. - Edit::SetCalculationOrder { names } => { - pdfcraft_forms::set_calculation_order(doc, names)?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) @@ -2882,7 +1324,7 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { } /// Parse another PDF to copy pages from. -fn open_source(name: &str, bytes: &Arc>) -> Result { +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) } @@ -2916,12 +1358,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} - #[derive(Default)] pub struct Session { docs: Vec, @@ -3061,7 +1497,7 @@ fn note_warnings(list: &mut Vec, new: &[String]) { /// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or /// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an /// encoding this can't read. -fn decode_data_file(bytes: &[u8]) -> Option> { +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { if bytes.starts_with(b"%FDF") { return None; } @@ -3095,7 +1531,7 @@ fn decode_data_file(bytes: &[u8]) -> Option> { /// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, /// buttons that did nothing but run scripts, and fields whose value differs from the form's /// data (data PdfCraft could not read into them, now lost). -fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { let fields = pdfcraft_forms::fields(doc); let data = xfa_field_data(doc); let differ = pdfcraft_xfa::read_values(doc, &data) @@ -3150,7 +1586,7 @@ const XFA_REMOVED_PREFIX: &str = "XFA removed: "; /// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), /// rather than XFDF, Acrobat's `` XML or a table? By its root element. -fn is_xfa_data_file(text: &str) -> bool { +pub(crate) fn is_xfa_data_file(text: &str) -> bool { let text = text.get(..text.len().min(65_536)).unwrap_or(text); let mut rest = text.trim_start_matches('\u{feff}').trim_start(); // Past the XML declaration, comments and processing instructions. @@ -3174,7 +1610,7 @@ fn is_xfa_data_file(text: &str) -> bool { } /// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -fn image_content_type(bytes: &[u8]) -> Option<&'static str> { +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { match pdfcraft_xfa::image::kind(bytes)? { pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), pdfcraft_xfa::image::Kind::Png => Some("image/png"), @@ -3187,7 +1623,7 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// After the XFA data changed outside the fields (an import, a picture): a laid-out form is /// laid out again from the data (then, when `calculate`, its calculations run), and the /// fields take the data's values. -fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; @@ -5142,7 +3578,7 @@ pub struct MarkFile { } /// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -fn embed_image( +pub(crate) fn embed_image( doc: &mut pdfcraft_cos::Document, name: &str, bytes: &[u8], @@ -5154,7 +3590,7 @@ fn embed_image( /// shape in a box of any other shape. A box that is already that shape (to within rounding) is /// kept exactly, so callers that size it themselves get their box back. An empty or non-finite /// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { if !rect.iter().all(|v| v.is_finite()) { return rect; } @@ -5174,7 +3610,7 @@ fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { let head = &f.bytes[..f.bytes.len().min(1024)]; if head.windows(5).any(|w| w == b"%PDF-") { let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; From 631fc13f13504c9d3395ce7410e6881e98d918ec Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:21:09 +0100 Subject: [PATCH 13/33] refactor(engine): move protection and the security summary to protection.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 158 +------------------------------- crates/engine/src/protection.rs | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 155 deletions(-) create mode 100644 crates/engine/src/protection.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0926c3ed28..fdf57c87fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub mod ocr; pub mod optimizer; pub mod print_raster; pub mod printcheck; +mod protection; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -236,6 +237,8 @@ pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use protection::unrestricted; +pub use protection::{Changes, Printing, Protection, SecuritySummary}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1083,161 +1086,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// Document Properties ▸ Security. -#[derive(Clone, Debug, PartialEq)] -pub struct SecuritySummary { - /// "AES, 256-bit" etc. - pub method: String, - /// Opened with the owner password (no restrictions apply). - pub owner: bool, - /// For pending protection: the restrictions as they will apply to others. - pub permissions: pdfcraft_cos::Permissions, - /// Set in this session; written by the next save. - pub pending: bool, -} - -/// What printing a protected document allows (Acrobat: Printing allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Printing { - None, - /// "Low Resolution (150 dpi)". - Low, - High, -} - -/// What changes a protected document allows (Acrobat: Changes allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Changes { - None, - /// "Inserting, deleting, and rotating pages". - Pages, - /// "Filling in form fields and signing existing signature fields". - FillSign, - /// "Commenting, filling in form fields, and signing existing signature fields". - CommentFillSign, - /// "Any except extracting pages". - AnyExceptExtract, -} - -/// Password protection to apply (Protect Using Password and its Advanced options). -#[derive(Clone, PartialEq)] -pub struct Protection { - /// Required to open the document (the user password). - pub open_password: Option, - /// Required to change security and lift the restrictions below (the owner password). - pub permissions_password: Option, - pub printing: Printing, - pub changes: Changes, - /// "Enable copying of text, images, and other content". - pub copy: bool, - /// "Enable text access for screen reader devices". - pub accessibility: bool, - /// Compatibility level; AES-256 (Acrobat X and later) by default. - pub algorithm: pdfcraft_cos::Algorithm, - /// `false`: "Encrypt all document contents except metadata". - pub encrypt_metadata: bool, -} - -impl std::fmt::Debug for Protection { - // Never print passwords (edits end up in logs and error messages). - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Protection") - .field("open_password", &self.open_password.as_ref().map(|_| "…")) - .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) - .field("printing", &self.printing) - .field("changes", &self.changes) - .field("copy", &self.copy) - .field("accessibility", &self.accessibility) - .field("algorithm", &self.algorithm) - .field("encrypt_metadata", &self.encrypt_metadata) - .finish() - } -} - -impl Default for Protection { - /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. - fn default() -> Self { - Self { - open_password: None, - permissions_password: None, - printing: Printing::High, - changes: Changes::None, - copy: false, - accessibility: true, - algorithm: pdfcraft_cos::Algorithm::Aes256, - encrypt_metadata: true, - } - } -} - -impl Protection { - /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. - pub fn permission_bits(&self) -> i32 { - let bit = |n: u32| 1i32 << (n - 1); - if self.permissions_password.is_none() { - return -1; // nothing restricted without a permissions password - } - let mut p = 0; - match self.printing { - Printing::None => {} - Printing::Low => p |= bit(3), - Printing::High => p |= bit(3) | bit(12), - } - p |= match self.changes { - Changes::None => 0, - Changes::Pages => bit(11), - Changes::FillSign => bit(9), - Changes::CommentFillSign => bit(6) | bit(9), - Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), - }; - if self.copy { - p |= bit(5) | bit(10); - } - if self.accessibility { - p |= bit(10); - } - p - } - - fn validate(&self) -> Result<(), EditError> { - let bad = |m: &str| Err(EditError::Protection(m.to_string())); - match (&self.open_password, &self.permissions_password) { - (None, None) => return bad("enter a password to open the document, a permissions password, or both"), - (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), - _ => {} - } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { - return bad("passwords can't be empty"); - } - if self.algorithm != pdfcraft_cos::Algorithm::Aes256 - && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) - { - return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); - } - // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, - // which drops soft hyphens and folds compatibility forms; older levels: the first 32 - // bytes, padded). One that comes out as the empty password opens the file without asking, - // and an open password that comes out as the permissions password lifts the restrictions. - let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); - let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); - let empty = effective(""); - if open.iter().chain(&permissions).any(|p| *p == empty) { - return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); - } - if open.is_some() && open == permissions { - return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); - } - Ok(()) - } -} - -/// Opened as owner, or nothing is restricted (no permissions password was set): security may -/// be changed, as in Acrobat. -pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { - const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 - p.owner || p.bits & ALL == ALL -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) diff --git a/crates/engine/src/protection.rs b/crates/engine/src/protection.rs new file mode 100644 index 0000000000..e66f01538a --- /dev/null +++ b/crates/engine/src/protection.rs @@ -0,0 +1,158 @@ +//! Password protection to apply, and the security a document has. + +use crate::EditError; + +/// Document Properties ▸ Security. +#[derive(Clone, Debug, PartialEq)] +pub struct SecuritySummary { + /// "AES, 256-bit" etc. + pub method: String, + /// Opened with the owner password (no restrictions apply). + pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. + pub permissions: pdfcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: pdfcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: pdfcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + pub(crate) fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { + return bad("passwords can't be empty"); + } + if self.algorithm != pdfcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, + // which drops soft hyphens and folds compatibility forms; older levels: the first 32 + // bytes, padded). One that comes out as the empty password opens the file without asking, + // and an open password that comes out as the permissions password lifts the restrictions. + let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); + let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); + let empty = effective(""); + if open.iter().chain(&permissions).any(|p| *p == empty) { + return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); + } + if open.is_some() && open == permissions { + return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); + } + Ok(()) + } +} + +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} From 05098de5421130dfe2ae013b4e6e1f06afa5008e Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:07 +0100 Subject: [PATCH 14/33] refactor(engine): move the open pipeline to open.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 345 +----------------------------------- crates/engine/src/open.rs | 355 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 342 deletions(-) create mode 100644 crates/engine/src/open.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fdf57c87fa..ac836aabc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,7 @@ mod history; pub mod js; pub mod links; pub mod ocr; +mod open; pub mod optimizer; pub mod print_raster; pub mod printcheck; @@ -234,7 +235,8 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; +use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +pub use open::{OpenContext, PreparedDocument}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; use protection::unrestricted; @@ -1230,35 +1232,6 @@ pub struct Session { xfa_time_scale: Option, } -/// Immutable session state needed while a document is prepared away from its owning thread. -#[derive(Clone)] -pub struct OpenContext { - now: Option, - js_off: bool, - trust: Arc, - xfa_time_scale: u32, -} - -/// A fully prepared document that has not yet been registered in a session. -pub struct PreparedDocument { - document: Document, -} - -impl OpenContext { - /// Prepare a document without mutating the session that supplied this context. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let name = name.into(); - guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) - } -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1649,318 +1622,6 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - /// Snapshot the session state that affects open-time scripts, dates and signature trust. - pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } - } - - /// Prepare a document without adding it to this session. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - self.open_context().prepare_open(name, path, bytes, password) - } - - /// Register a prepared document. This is the only open step that mutates the session. - pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { - self.next_id += 1; - let id = DocId(self.next_id); - let mut document = prepared.document; - document.id = id; - self.docs.push(document); - id - } - - /// Synchronous convenience API used by the CLI and automation. - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let prepared = self.prepare_open(name, path, bytes, password)?; - Ok(self.commit_open(prepared)) - } - - fn prepare_open_unguarded( - context: &OpenContext, - name: String, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); - // The renderer authenticates on its own. It cannot use the owner password of R2–R4 - // files, so give it the user password that owner authentication recovers. - let (info, render_password) = match inspect(bytes.clone(), password) { - Ok(info) => (info, password.map(str::to_owned)), - Err(OpenError::WrongPassword) => { - let user = match &cos { - Ok(Ok(d)) => recovered_render_password(d), - _ => None, - }; - let user = user.ok_or(OpenError::WrongPassword)?; - (inspect(bytes.clone(), Some(&user))?, Some(user)) - } - Err(e) => return Err(e), - }; - // A dynamic XFA form is a shell around an XML template; lay the template out into real - // pages and fields so the rest of the engine works on it. The original bytes stay: the - // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. - let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { - let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; - let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; - Ok((new_bytes, new_info, new_cos)) - }; - let (bytes, info, cos, xfa) = match (info.xfa, cos) { - (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { - let mut work = cos.clone(); - let laid_out = guard(|| xfa_layout(&mut work)) - .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) - .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); - match laid_out { - Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), - Err(e) => { - let mut info = info; - info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); - (bytes, info, Ok(Ok(cos)), None) - } - } - } - // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values - // filled in by another viewer since; give the fields those values. - (Some(_), Ok(Ok(cos))) => { - let report = pdfcraft_xfa::existing_layout(&cos); - let mut work = cos.clone(); - let synced = - guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); - match synced { - Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), - Ok(names) => match rebase(&work) { - Ok((b, mut i, c)) => { - // The fields were rewritten from the XFA data: say so, and which. - let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); - let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; - i.warnings.push(format!( - "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", - names.len(), - if names.len() == 1 { "" } else { "s" }, - shown.join(", ") - )); - (b, i, Ok(Ok(c)), report) - } - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - }, - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - } - } - (_, cos) => (bytes, info, cos, None), - }; - // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in - // Acrobat; what they change is one more revision, still nothing to save. - let mut script_output = js::JsOutput::default(); - let mut ran_away = false; - let xfa_template = match (&xfa, &cos) { - (Some(_), Ok(Ok(c))) => xfa::template(c), - _ => None, - }; - let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { - Some(tpl) => { - let mut work = cos.clone(); - let pages = info.pages.len(); - let ran = guard(|| { - let mut run = xfa::XfaRun { - page_count: pages, - out: &mut script_output, - datasets: None, - ran_away: false, - time_scale: context.xfa_time_scale, - }; - let changes = xfa::on_open(&mut work, &tpl, &mut run); - if run.ran_away { - ran_away = true; - } - changes - }) - .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); - match ran { - Ok(changes) if work.is_modified() => match rebase(&work) { - Ok((b, mut i, c)) => { - // The file as saved differs from what opening shows: say so. - if !changes.is_empty() { - i.warnings.push(format!( - "This form's scripts changed it on opening ({}); saving keeps those changes", - changes.describe() - )); - } - (b, i, Ok(Ok(c))) - } - Err(e) => { - script_output.errors.push(format!("The form's scripts could not be applied: {e}")); - (bytes, info, Ok(Ok(cos))) - } - }, - Ok(_) => (bytes, info, Ok(Ok(cos))), - Err(e) => { - script_output.errors.push(format!("The form's scripts could not run: {e}")); - (bytes, info, Ok(Ok(cos))) - } - } - } - None => (bytes, info, Ok(Ok(cos))), - }, - (_, cos) => (bytes, info, cos), - }; - // The catalog leads to no page, but the reader found pages by scanning: gather the page - // objects into a page tree as one more revision, or page edits address none of them - // (#1656). Kept only when the reader then shows the same pages, and never on a signed - // document, where writing the repair could rewrite the file under its signatures. - let (bytes, info, cos) = match cos { - Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { - let mut work = cos.clone(); - let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); - let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { - Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), - _ => None, - }; - match rebuilt { - Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } - c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); - (b, i, Ok(Ok(c))) - } - None => (bytes, info, Ok(Ok(cos))), - } - } - cos => (bytes, info, cos), - }; - // XFA notes survive the document being re-read after edits. - let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); - // A script that ran away on open turns the form's scripts off for this document. - document.xfa_template = if ran_away { None } else { xfa_template }; - document.js_output.append(script_output); - if ran_away { - document.xfa_scripts_off = true; - document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut document.xfa_warnings, &xfa_warnings); - Ok(PreparedDocument { document }) - } - - /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; - let (mut editor, read_only_reason) = match cos { - Ok(Ok(cos)) => { - let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; - let saved = cos.bytes().clone(); - ( - Some(Editor { - cos, - saved, - saved_keys: keys.clone(), - unsaved_file: false, - undo: Vec::new(), - redo: Vec::new(), - keys, - appearances: AppearanceCache::default(), - field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), - text_blocks: TextBlocksCache::default(), - }), - None, - ) - } - Ok(Err(e)) => (None, Some(e.to_string())), - Err(_) => (None, Some("the document structure could not be read for editing".into())), - }; - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); - // The inspection reads only `/Info`; a file stating its title or author only in XMP still - // shows them (#2373). - if let Some(e) = &editor { - for (field, value) in info_fields(&mut info, &e.cos) { - if field.is_none() { - *field = value; - } - } - } - let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); - let (form, adopted) = editor - .as_ref() - .map(|e| { - let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); - xfa::mark_script_buttons(&e.cos, &mut fields.items); - (fields.items, fields.adopted_page_fields) - }) - .unwrap_or_default(); - // Fields the file lists only as page widgets are adopted leniently; the file says so. - if adopted > 0 - && let Some(e) = &mut editor - { - let line = if adopted == 1 { - "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() - } else { - format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") - }; - e.cos.note_repair(line); - } - Document { - id: DocId(0), - name, - path, - bytes, - display, - info, - renderer, - password: render_password, - dirty: false, - generation: 0, - snapshot_generation: 0, - read_only_reason, - form: Arc::new(form), - marker_scan: OnceLock::new(), - links: OnceLock::new(), - signatures: Arc::new(signatures), - trust: trust.clone(), - sig_cache, - editor, - config, - js_output: Default::default(), - xfa, - xfa_template: None, - xfa_scripts_off: false, - fonts_stale: false, - xfa_warnings: Vec::new(), - edit_notices: Vec::new(), - } - } - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..d8950d1d6d --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,355 @@ +//! Opening a document: preparing it away from the session, then registering it. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; + +use crate::history::{Editor, Keys, TextBlocksCache}; +use crate::{ + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings, + recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, +}; + +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, + xfa_time_scale: u32, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + +impl Session { + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + } + + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); + // The renderer authenticates on its own. It cannot use the owner password of R2–R4 + // files, so give it the user password that owner authentication recovers. + let (info, render_password) = match inspect(bytes.clone(), password) { + Ok(info) => (info, password.map(str::to_owned)), + Err(OpenError::WrongPassword) => { + let user = match &cos { + Ok(Ok(d)) => recovered_render_password(d), + _ => None, + }; + let user = user.ok_or(OpenError::WrongPassword)?; + (inspect(bytes.clone(), Some(&user))?, Some(user)) + } + Err(e) => return Err(e), + }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) + } + + /// The last step of opening: build the document record and register it. + #[allow(clippy::too_many_arguments)] + fn build_document( + trust: &Arc, + name: String, + path: Option, + bytes: Arc>, + mut info: DocInfo, + cos: Result, Box>, + render_password: Option, + password: Option<&str>, + xfa: Option, + ) -> Document { + let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let (mut editor, read_only_reason) = match cos { + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); + ( + Some(Editor { + cos, + saved, + saved_keys: keys.clone(), + unsaved_file: false, + undo: Vec::new(), + redo: Vec::new(), + keys, + appearances: AppearanceCache::default(), + field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), + text_blocks: TextBlocksCache::default(), + }), + None, + ) + } + Ok(Err(e)) => (None, Some(e.to_string())), + Err(_) => (None, Some("the document structure could not be read for editing".into())), + }; + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } + let display = display_bytes(editor.as_mut(), &bytes); + let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); + let (form, adopted) = editor + .as_ref() + .map(|e| { + let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); + xfa::mark_script_buttons(&e.cos, &mut fields.items); + (fields.items, fields.adopted_page_fields) + }) + .unwrap_or_default(); + // Fields the file lists only as page widgets are adopted leniently; the file says so. + if adopted > 0 + && let Some(e) = &mut editor + { + let line = if adopted == 1 { + "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() + } else { + format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") + }; + e.cos.note_repair(line); + } + Document { + id: DocId(0), + name, + path, + bytes, + display, + info, + renderer, + password: render_password, + dirty: false, + generation: 0, + snapshot_generation: 0, + read_only_reason, + form: Arc::new(form), + marker_scan: OnceLock::new(), + links: OnceLock::new(), + signatures: Arc::new(signatures), + trust: trust.clone(), + sig_cache, + editor, + config, + js_output: Default::default(), + xfa, + xfa_template: None, + xfa_scripts_off: false, + fonts_stale: false, + xfa_warnings: Vec::new(), + edit_notices: Vec::new(), + } + } +} From f1489157303bdc831ac606405905f074aaad6582 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:54 +0100 Subject: [PATCH 15/33] refactor(engine): move the other crates' re-exports to reexports.rs The doc comment for SplitPart had drifted above the LabelStyle re-export; it is back on SplitPart. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 53 ++-------------------------------- crates/engine/src/reexports.rs | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/reexports.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ac836aabc1..6edc4d7b7e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod optimizer; pub mod print_raster; pub mod printcheck; mod protection; +mod reexports; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -51,36 +52,7 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub use pdfcraft_organize::LabelStyle; -pub use pdfcraft_organize::Standard; -pub use pdfcraft_organize::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, -}; -pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, -}; -pub use pdfcraft_forms::{ - BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, - FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, - Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, -}; - -pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +pub use reexports::*; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -103,7 +75,6 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -151,25 +122,6 @@ pub fn typed_signature_graphic(text: &str) -> Option { pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a /// filled-in PDF form), as CSV with one row per file. @@ -179,6 +131,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { Ok(pdfcraft_xfdf::merge_csv(&rows)) } +/// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); use std::sync::{Arc, OnceLock}; diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs new file mode 100644 index 0000000000..208a8e450b --- /dev/null +++ b/crates/engine/src/reexports.rs @@ -0,0 +1,53 @@ +//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. + +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; + +pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; +pub use pdfcraft_organize::check_custom_key; +pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; + +pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{ + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, +}; +pub use pdfcraft_edit::{ + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, +}; +pub use pdfcraft_forms::{ + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + calculations_changed as form_calculations_changed, flags as field_flags, +}; + +pub use pdfcraft_a11y as a11y; +pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_measure as measure; +pub use pdfcraft_xfa::Report as XfaLayout; + +pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; + +/// Comment geometry helpers (text-box line breaking) for frontends. +pub use pdfcraft_annot::appearance as annot_text; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::{ + AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, + ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, +}; +pub use pdfcraft_forms::detect; +pub use pdfcraft_optimize as optimize; +pub use pdfcraft_preflight as pdfa; +pub use pdfcraft_print as print; +pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; +pub use pdfcraft_redact::patterns::{ + MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, +}; +pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_sign as sign; +pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; +pub use pdfcraft_xfdf::Format as DataFormat; From 5b7518075950d1b2da929ffe79ba9d46dd8c8f62 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:24:44 +0100 Subject: [PATCH 16/33] refactor(engine): move Session and its operations to session.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 1581 +-------------------------------- crates/engine/src/session.rs | 1590 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 2 + 3 files changed, 1598 insertions(+), 1575 deletions(-) create mode 100644 crates/engine/src/session.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6edc4d7b7e..91d22accd7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ mod protection; mod reexports; mod scan_colours; mod scan_fields; +mod session; pub mod signature_image; pub mod signature_scan; mod stamp_preview; @@ -184,16 +185,17 @@ fn record_link_scan() { }); } +use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; -use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; +use history::Editor; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; +pub use session::Session; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1161,30 +1163,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -#[derive(Default)] -pub struct Session { - docs: Vec, - next_id: u64, - /// Seconds since the Unix epoch, injected so saves are deterministic in tests. - clock: Option i64>, - /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). - /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the - /// injected time AS local time and zeroes the zone, so injecting only it leaves either the - /// local dates or the UTC ones wrong by the zone's offset. - utc_offset: Option i64>, - /// Certificates trusted for signing (Acrobat: Trusted Certificates). - trust: Arc, - /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). - js_off: bool, - /// Preferences ▸ Date format, when not the default (see [`dates`]). - date_format: Option, - /// Preferences ▸ Date format ▸ Language, when not following the interface language. - date_language: Option, - /// What the XFA scripts' time limits are multiplied by (`None`: 1); see - /// [`Session::with_xfa_time_scale`]. - xfa_time_scale: Option, -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1483,1553 +1461,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -impl Session { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the - /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script - /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the - /// scripts do passes a large factor, so that a busy machine does not stop them. On native - /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so - /// there the factor has no effect. - pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { - self.xfa_time_scale = Some(factor); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.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()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .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 = 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. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - let previous = std::mem::replace(&mut editor.cos, next); - let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); - let previous_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - #[cfg(test)] - check_refresh_panic(); - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// The bytes to write for Save: an incremental update of the file as opened/last saved, - /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - // What the user removed again before saving (a cleared redaction mark) stays out of the file. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; - if !typed.is_empty() && doc.info.xfa.is_some() { - let mut datasets = None; - let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); - } - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - if let Some(p) = path { - doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // How the document is shown is the reader's, not the file's: reading colours and hidden - // comments stay as they were (#3443). `open` built the renderer with the defaults. - if let Some(old) = self.docs.get(old) - && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) - { - doc.config.reading_colors = old.config.reading_colors; - doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - let mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`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>, 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> { - let docs: Vec> = - 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(Option::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 { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - 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_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 - /// 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 (see - /// `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>, EditError> { - 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 { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - 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_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). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Whether a changed document still needs a recovery snapshot. This does not consume it. - pub fn has_pending_autosave(&self) -> bool { - self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926, #3944): a recovery file - // is written from a copy without them, and none is written if that copy fails. - let bytes = - match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - d.generation += 1; - d.snapshot_generation = d.generation; // its bytes are already in the recovery store - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns - /// `true` if any layer changed; callers must drop cached rasters and text for the document, - /// as for [`Self::set_layer_visible`]. - pub fn reset_layers(&mut self, id: DocId) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let mut changed = false; - for l in &mut doc.info.layers { - changed |= l.visible != l.initial; - l.visible = l.initial; - } - if changed { - use_layer_choices(doc); - } - changed - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. - pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} - /// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` /// (0-based) of a PDF. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..c3e34d90bd --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,1590 @@ +//! The session: the open documents and the operations on them. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; +use pdfcraft_render::{LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; + +#[cfg(test)] +use crate::check_refresh_panic; +use crate::edit::{EditCtx, check_permission, run_edit}; +use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +use crate::{ + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, + LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js, + local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads, + scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, + writable, xfa, xfa_sync_datasets, +}; + +#[derive(Default)] +pub struct Session { + pub(crate) docs: Vec, + pub(crate) next_id: u64, + /// Seconds since the Unix epoch, injected so saves are deterministic in tests. + clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, + /// Certificates trusted for signing (Acrobat: Trusted Certificates). + pub(crate) trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + pub(crate) js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + pub(crate) date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + pub(crate) date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + pub(crate) xfa_time_scale: Option, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.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()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .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 = 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. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() + } else { + Vec::new() + }; + let previous = std::mem::replace(&mut editor.cos, next); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// The bytes to write for Save: an incremental update of the file as opened/last saved, + /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + if let Some(p) = path { + doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium + /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects + /// packed into compressed object streams. Returns the bytes and how many objects were + /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + let mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`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>, 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> { + let docs: Vec> = + 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(Option::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 { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + 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_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 + /// 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 (see + /// `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>, EditError> { + 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 { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + 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_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). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 360517a87c..d06faefc8b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,8 @@ use std::sync::Arc; +use super::edit::check_permission; use super::*; +use pdfcraft_render::{OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From c4dc7e91268577bc74507ee5961ae0754f6a7fc9 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:29:41 +0100 Subject: [PATCH 17/33] refactor(engine): keep the moved docs' intra-doc links resolving Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 6 +++--- crates/engine/src/history.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index a29e4a870b..faf4712078 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -364,7 +364,7 @@ pub enum Edit { activity: String, }, /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). + /// image (from [`ocr::OcrJob::run`](crate::ocr::OcrJob::run)). AddOcrText { page: usize, words: Vec, @@ -409,7 +409,7 @@ pub enum Edit { earlier: bool, }, /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. + /// in their current order (see [`Document::calculation_order`](crate::Document::calculation_order)). The form recalculates. SetCalculationOrder { names: Vec, }, @@ -469,7 +469,7 @@ pub enum Edit { }, /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// picture is `file` (from [`Document::added_image_file`](crate::Document::added_image_file)): the copied image object is reused /// while it is still that picture in this document, else one an added image here already draws /// it from, else the file is embedded. PasteContent { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 086d98a6ab..fafa65b4c2 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -169,7 +169,7 @@ impl Editor { /// the two cannot drift apart. #[derive(Clone, Debug, PartialEq)] pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS). pub wording: &'static str, /// What the document supplies, when the wording has a hole for it. pub value: Option, From ff361fe13837515ec1fb2690704079c31d66978b Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:07 +0100 Subject: [PATCH 18/33] refactor(engine): move Document and its display helpers to document.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 713 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 708 +-------------------------------- crates/engine/src/tests.rs | 1 + 3 files changed, 719 insertions(+), 703 deletions(-) create mode 100644 crates/engine/src/document.rs diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs new file mode 100644 index 0000000000..0c85f51af6 --- /dev/null +++ b/crates/engine/src/document.rs @@ -0,0 +1,713 @@ +//! An open document: its working file, view data and edit state. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; + +use crate::history::Editor; +use crate::{ + CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, + SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, +}; +#[cfg(test)] +use crate::{record_link_scan, record_marker_scan}; + +pub struct Document { + pub id: DocId, + pub name: String, + pub path: Option, + /// The working file: what Save writes. + pub bytes: Arc>, + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances + /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + pub display: Arc>, + pub info: DocInfo, + pub renderer: RenderPool, + /// The password the document was opened with (needed to read attachments, etc.). + pub password: Option, + /// `true` when there are edits that have not been saved. + pub dirty: bool, + /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. + pub(crate) generation: u64, + /// The generation last handed out by `autosave_snapshots`. + pub(crate) snapshot_generation: u64, + /// Why the document cannot be edited (e.g. encryption), if so. + pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, + pub(crate) marker_scan: OnceLock, + pub(crate) links: OnceLock>, + /// Signature fields, validated against the session's trust store (Signatures panel). + pub signatures: Arc>, + pub(crate) trust: Arc, + pub(crate) sig_cache: Arc, + pub(crate) editor: Option, + pub(crate) config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + pub(crate) js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// The parsed template of a laid-out XFA form, for its scripts. + pub(crate) xfa_template: Option>, + /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). + pub(crate) xfa_scripts_off: bool, + /// A content-only refresh keeps `info` from before the edit; an edit may have added or + /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). + pub(crate) fonts_stale: bool, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out +} + +impl Document { + /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// A script of this XFA form ran past its time limit and was abandoned, so the form's + /// scripts no longer run in this document (reopening it runs them again). The script's + /// error is in the JavaScript output; this is the state, for a viewer to show. + pub fn xfa_scripts_off(&self) -> bool { + self.xfa_scripts_off + } + + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + if img.is_form { + return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); + } + pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() + } + + /// Saved measurement annotations, calculated from their geometry and PDF scales, plus + /// the ones that couldn't be read (unsupported formats are skipped, not fatal). + pub fn measurements(&self) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + Ok(measure::list(&e.cos)) + } + pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) + } + /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a + /// point) where the page sets no scale. + pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) + } + pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_paths(&self, page: usize) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) + } + + /// A counter that changes with every edit (for caches of derived data). + pub fn edit_generation(&self) -> u64 { + self.generation + } + + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). + pub fn text_lines(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() + } + + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + + /// Add alternate text: the figures, in document order. + pub fn figures(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() + } + + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + + /// Signed: at least one signature field holds a signature. + pub fn is_signed(&self) -> bool { + self.signatures.iter().any(|s| s.signed) + } + + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + + pub fn can_undo(&self) -> Option<&str> { + self.undo_label().map(Label::as_str) + } + + pub fn can_redo(&self) -> Option<&str> { + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) + } + + pub fn editable(&self) -> bool { + self.editor.is_some() + } + + /// What the opening password allows; `None` when the document is not encrypted. + pub fn permissions(&self) -> Option { + self.editor.as_ref().and_then(|e| e.cos.permissions()) + } + + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + + /// Page changes (insert, delete, rotate, move, extract) are allowed. + pub fn allows_assembly(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.assemble()) + } + + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + + /// Changes to content and document information are allowed. + pub fn allows_modification(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.modify()) + } + + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + + /// Adding and changing comments is allowed (Table 22, bit 6). + pub fn allows_annotation(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.annotate()) + } + + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A password restricts the document and it wasn't opened with the owner password: entering + /// that password ([`Session::unlock`]) lifts the restrictions. + pub fn can_unlock(&self) -> bool { + self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). + pub fn security_summary(&self) -> Option { + let editor = self.editor.as_ref()?; + let h = editor.cos.output_handler()?; + let d = h.dict(); + let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); + let method = match (d.v, stream) { + (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", + (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", + _ => "Attachments only", + }; + let pending = editor.cos.encryption_changed(); + let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) + } + + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) + } + + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(pdfcraft_forms::look(&e.cos, f)) + } + + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) + } + + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + + /// Verification: faux redactions (opaque covers over extractable text) and redaction + /// marks that were never applied. Fails closed: any read error is returned, never an + /// empty list. + pub fn audit_redactions(&self) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) + } + + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + + /// Current value of a document-information entry (Title, Author, …). + pub fn info_value(&self, key: &str) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) + } + + /// Whether the document information has an entry `key`, of any type, listed or not. + pub fn has_info(&self, key: &str) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) + } + + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + + /// The name shown on the tab and window: the document title when the document asks for it + /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. + pub fn display_name(&self) -> String { + self.editor + .as_ref() + .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) + .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) + .map(|t| t.trim().to_owned()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) + .unwrap_or_else(|| self.name.clone()) + } + + /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that + /// revision). Unsaved edits aren't a revision yet. + pub fn revision_ends(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() + } + + /// The file as last opened or saved, without unsaved edits: what an incremental save appends + /// to. This stays that file when undo has restored an older snapshot. + pub fn saved_bytes(&self) -> Arc> { + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) + } + + /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). + pub fn repair_log(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() + } +} + +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +pub(crate) fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + +/// The working file plus appearances for comments that have none, and for form fields the file +/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the +/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. +pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); + let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); + let Some(display) = fields.or(comments) else { + return bytes.clone(); + }; + match write_incremental(&display, &SaveOptions::default()) { + Ok(written) => Arc::new(written), + Err(err) => { + e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); + bytes.clone() + } + } +} + +/// `comments` (the display copy with comment appearances, if it needed any; else the document +/// itself) with its form fields drawn from their values where the file leaves that to the viewer. +fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { + pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) +} + +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + +/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for +/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page +/// images agree with the app and independent viewers. It never changes the source document, and an +/// unparseable file, a write failure or a parser panic falls back to the original bytes. +pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + +/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with +/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their +/// owner password this is the user password that owner authentication recovers; otherwise it is +/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner +/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps +/// it, so the renderer reports the same error it always did. +pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + +/// Fails when the renderer still draws text under an applied redaction mark. +/// +/// Redaction decides what lies under a mark from the positions its own interpreter computes, and +/// its verify pass reuses them. Where they differ from the renderer's (a standard font without +/// /Widths, whose advances the interpreter approximates; a CMap +/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the +/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is +/// what the user saw when placing the marks, so it is asked. `after` is written as a save would +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its +/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps +/// the document as it was. +pub(crate) fn shown_under_redactions( + after: &pdfcraft_cos::Document, + marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], + pages: &[pdfcraft_render::PageInfo], + password: Option<&str>, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 91d22accd7..15f6629e07 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod document; mod edit; mod error; pub mod export; @@ -135,7 +136,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { /// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; #[cfg(test)] thread_local! { @@ -185,14 +186,14 @@ fn record_link_scan() { }); } +pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; +use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; -use history::Editor; pub use history::Label; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; +use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -205,705 +206,6 @@ pub struct DocId(pub u64); /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -pub struct Document { - pub id: DocId, - pub name: String, - pub path: Option, - /// The working file: what Save writes. - pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. - pub display: Arc>, - pub info: DocInfo, - pub renderer: RenderPool, - /// The password the document was opened with (needed to read attachments, etc.). - pub password: Option, - /// `true` when there are edits that have not been saved. - pub dirty: bool, - /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. - generation: u64, - /// The generation last handed out by `autosave_snapshots`. - snapshot_generation: u64, - /// Why the document cannot be edited (e.g. encryption), if so. - pub read_only_reason: Option, - /// Interactive form fields of the current state (empty without a form). - pub form: Arc>, - marker_scan: OnceLock, - links: OnceLock>, - /// Signature fields, validated against the session's trust store (Signatures panel). - pub signatures: Arc>, - trust: Arc, - sig_cache: Arc, - editor: Option, - config: RenderConfig, - /// What field scripts printed or asked for (see [`Session::take_js_output`]). - js_output: js::JsOutput, - /// Dynamic XFA forms: what laying the template out produced (pages and fields are - /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). - pub xfa: Option, - /// The parsed template of a laid-out XFA form, for its scripts. - xfa_template: Option>, - /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). - xfa_scripts_off: bool, - /// A content-only refresh keeps `info` from before the edit; an edit may have added or - /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). - fonts_stale: bool, - /// XFA forms: what was approximated, rewritten or could not be written to the XFA data - /// (also in `info.warnings`, kept there when the document is re-read). - pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, -} - -/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn -/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat -/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TextNotice { - /// The form field's full name, or `None` for a comment. - pub field: Option, - /// The page (0-based) of the comment or of the field's widget. - pub page: usize, - pub ch: char, - /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` - /// set); otherwise the `?` shows in every viewer. - pub redrawn_elsewhere: bool, -} - -impl std::fmt::Display for TextNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); - let page = self.page + 1; - match (&self.field, self.redrawn_elsewhere) { - (Some(name), true) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" - ), - (Some(name), false) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" - ), - (None, _) => write!( - f, - "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" - ), - } - } -} - -/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying -/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. -fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { - let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; - let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); - let mut names: Option> = None; - let mut out: Vec = Vec::new(); - for (page, p) in pages.iter().enumerate() { - let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); - for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { - let now = next.get(r); - let Some(d) = now.as_dict() else { continue }; - let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; - // Only what this edit drew: a new annotation, or a new appearance. - let was = prev.get(r); - if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { - continue; - } - let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { - let names = names.get_or_insert_with(|| { - pdfcraft_forms::fields(next) - .into_iter() - .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) - .collect() - }); - names.get(&r).cloned().unwrap_or_default() - }); - if field.is_some() && out.iter().any(|n| n.field == field) { - continue; - } - out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); - } - } - out -} - -impl Document { - /// How the document opens (Document Properties ▸ Initial View). - /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be - /// read for editing). - pub fn accessibility_check(&self, options: &a11y::Options) -> Option { - self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) - } - - /// A script of this XFA form ran past its time limit and was abandoned, so the form's - /// scripts no longer run in this document (reopening it runs them again). The script's - /// error is in the JavaScript output; this is the state, for a viewer to show. - pub fn xfa_scripts_off(&self) -> bool { - self.xfa_scripts_off - } - - /// PDF Optimizer ▸ Audit space usage. - pub fn audit_space(&self) -> Vec { - self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() - } - - /// Edit a PDF: the images `page` draws (0-based). - pub fn page_images(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() - } - - /// Mixed editable objects on one page. Source inventories are zero-based and added items - /// appear once. Rectangles are user-space points, independent of the viewer's zoom. - pub fn editable_objects(&self, page: usize) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) - } - - /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, - /// so even a large crop-box origin cannot erase a small drag by cancellation. - pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { - let p = self.info.pages.get(page).ok_or("the page no longer exists")?; - let [x0, y0, x1, y1] = p.crop.map(f64::from); - let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); - let (u, v) = match p.rotation { - 90 => (b, -a), - 180 => (-a, -b), - 270 => (-b, a), - _ => (a, b), - }; - let offset = [u * (x1 - x0), -v * (y1 - y0)]; - if !offset.iter().all(|v| v.is_finite()) { - return Err("the move must be finite and non-zero".into()); - } - Ok(offset) - } - - /// Save image as: image `index` on `page` as a file (extension, bytes). - pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { - self.check_copying().map_err(|e| e.to_string())?; - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; - if img.is_form { - return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); - } - pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) - } - - /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). - pub fn text_blocks(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() - } - - /// Saved measurement annotations, calculated from their geometry and PDF scales, plus - /// the ones that couldn't be read (unsupported formats are skipped, not fatal). - pub fn measurements(&self) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - Ok(measure::list(&e.cos)) - } - pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) - } - /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a - /// point) where the page sets no scale. - pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) - } - pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_paths(&self, page: usize) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) - } - - /// A counter that changes with every edit (for caches of derived data). - pub fn edit_generation(&self) -> u64 { - self.generation - } - - /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). - pub fn text_lines(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() - } - - /// The form's calculation order: its calculated fields in the order they calculate, what each - /// formula reads, what is wrong with the order, and the order Fix order writes. - pub fn calculation_order(&self) -> CalculationOrder { - self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() - } - - /// Add alternate text: the figures, in document order. - pub fn figures(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() - } - - /// Reachable indirect structure tags, in document order. - pub fn tags(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() - } - - /// Marked-content items directly owned by one reachable indirect structure tag. - pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { - self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) - } - - /// The edit that fixes `rule`, for the rules with an automatic fix: the document language - /// (`value` is the language), the title (`value` replaces it; else the current title or the - /// file name) and the tab order. - pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|v| !v.is_empty()); - match rule { - a11y::Rule::PrimaryLanguage => { - let mut v = self.initial_view(); - v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); - Ok(Edit::SetInitialView(Box::new(v))) - } - a11y::Rule::Title => { - let title = value - .map(str::to_owned) - .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) - .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); - let mut v = self.initial_view(); - v.display_title = true; - Ok(Edit::Batch { - label: "Set document title".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], - }) - } - a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), - other => Err(format!("\"{}\" has no automatic fix", other.name())), - } - } - - pub fn initial_view(&self) -> InitialView { - self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() - } - - /// Signed: at least one signature field holds a signature. - pub fn is_signed(&self) -> bool { - self.signatures.iter().any(|s| s.signed) - } - - /// Whether comments are hidden (Comments ▸ Hide all comments). - pub fn comments_hidden(&self) -> bool { - self.config.hide_comments - } - - pub fn can_undo(&self) -> Option<&str> { - self.undo_label().map(Label::as_str) - } - - pub fn can_redo(&self) -> Option<&str> { - self.redo_label().map(Label::as_str) - } - - /// What Undo would undo, in parts, for an interface that says it in another language. - pub fn undo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) - } - - /// What Redo would redo, in parts. - pub fn redo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) - } - - pub fn editable(&self) -> bool { - self.editor.is_some() - } - - /// What the opening password allows; `None` when the document is not encrypted. - pub fn permissions(&self) -> Option { - self.editor.as_ref().and_then(|e| e.cos.permissions()) - } - - /// Printing is allowed (Table 22, bit 3). - pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) - } - - /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying - /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the - /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. - pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) - } - - /// [`Self::allows_copying`], as the error to report when it isn't. - pub fn check_copying(&self) -> Result<(), EditError> { - if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } - } - - /// Page changes (insert, delete, rotate, move, extract) are allowed. - pub fn allows_assembly(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.assemble()) - } - - /// Redaction marks waiting to be applied. - pub fn redaction_marks(&self) -> usize { - self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() - } - - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub fn marks(&self) -> &[MarkKind] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .marks - } - - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub fn added(&self) -> &[pdfcraft_edit::Added] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .added - } - - /// Link annotations, for Edit a PDF ▸ Link. - pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { - self.links.get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_link_scan(); - pdfcraft_annot::links::list(&e.cos) - }) - .unwrap_or_default() - }) - } - - /// Changes to content and document information are allowed. - pub fn allows_modification(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.modify()) - } - - /// How widget `widget` of text field `name` lays its value out, for asking whether a typed - /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no - /// more than fits. `None` without such a field or widget, or when the document can't be - /// edited. - pub fn field_text_box(&self, name: &str, widget: usize) -> Option { - let cos = &self.editor.as_ref()?.cos; - let f = self.form.iter().find(|f| f.name == name)?; - let w = f.widgets.get(widget)?; - Some(form_appearance::text_box(cos, f, w)) - } - - /// Filling in form fields is allowed (Table 22, bits 6 and 9). - pub fn allows_form_filling(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) - } - - /// Adding and changing comments is allowed (Table 22, bit 6). - pub fn allows_annotation(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.annotate()) - } - - /// The document's security may be changed (Protect, Remove security): it is editable and, - /// if encrypted, was opened with the owner password. - pub fn allows_security_change(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) - } - - /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. - pub fn can_unlock(&self) -> bool { - self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) - } - - /// A summary of the document's security for Document Properties ▸ Security, including - /// protection applied in this session (written by the next save). - pub fn security_summary(&self) -> Option { - let editor = self.editor.as_ref()?; - let h = editor.cos.output_handler()?; - let d = h.dict(); - let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); - let method = match (d.v, stream) { - (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", - (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", - _ => "Attachments only", - }; - let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) - } - - /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). - pub fn comment_props(&self, page: usize, index: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) - } - - /// A form field's Appearance-tab look (borders, colours, font). - pub fn field_look(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - Some(pdfcraft_forms::look(&e.cos, f)) - } - - /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. - pub fn field_check_style(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) - } - - /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): - /// the page (0-based) of the first widget that cuts it, and how. - pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) - } - - /// Remove Hidden Information: what each category would remove. - pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { - self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() - } - - /// Verification: faux redactions (opaque covers over extractable text) and redaction - /// marks that were never applied. Fails closed: any read error is returned, never an - /// empty list. - pub fn audit_redactions(&self) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) - } - - /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. - pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { - self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() - } - - /// Current value of a document-information entry (Title, Author, …). - pub fn info_value(&self, key: &str) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) - } - - /// Whether the document information has an entry `key`, of any type, listed or not. - pub fn has_info(&self, key: &str) -> bool { - self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) - } - - /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. - pub fn custom_info(&self) -> Vec<(String, String)> { - self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() - } - - /// The name shown on the tab and window: the document title when the document asks for it - /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. - pub fn display_name(&self) -> String { - self.editor - .as_ref() - .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) - .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) - .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty() && !is_placeholder_title(t)) - .unwrap_or_else(|| self.name.clone()) - } - - /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that - /// revision). Unsaved edits aren't a revision yet. - pub fn revision_ends(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() - } - - /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. This stays that file when undo has restored an older snapshot. - pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) - } - - /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). - pub fn repair_log(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() - } -} - -/// A title that names no document: what a web browser writes when it saves a blank page or a -/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a -/// title says less than the file name, so the file name is shown instead. An address is one -/// word: "About: our company" is a real title. -fn is_placeholder_title(title: &str) -> bool { - let t = title.trim().to_lowercase(); - let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); - address || t == "untitled" -} - -/// The working file plus appearances for comments that have none, and for form fields the file -/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the -/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(e) = editor else { return bytes.clone() }; - let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); - let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); - let Some(display) = fields.or(comments) else { - return bytes.clone(); - }; - match write_incremental(&display, &SaveOptions::default()) { - Ok(written) => Arc::new(written), - Err(err) => { - e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); - bytes.clone() - } - } -} - -/// `comments` (the display copy with comment appearances, if it needed any; else the document -/// itself) with its form fields drawn from their values where the file leaves that to the viewer. -fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { - pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) -} - -/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. -fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) -} - -/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { - let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - [ - (&mut info.title, read("Title")), - (&mut info.author, read("Author")), - (&mut info.subject, read("Subject")), - (&mut info.keywords, read("Keywords")), - (&mut info.creator, read("Creator")), - (&mut info.producer, read("Producer")), - ] -} - -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page -/// images agree with the app and independent viewers. It never changes the source document, and an -/// unparseable file, a write failure or a parser panic falls back to the original bytes. -pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { - guard(|| { - pdfcraft_cos::Document::open_with_password(bytes.clone(), password) - .ok() - .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) - }) - .ok() - .flatten() - .unwrap_or(bytes) -} - -/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with -/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their -/// owner password this is the user password that owner authentication recovers; otherwise it is -/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner -/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps -/// it, so the renderer reports the same error it always did. -pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { - let password = password?; - let recovered = - guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); - Some(recovered.unwrap_or_else(|| password.to_owned())) -} - -/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. -fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { - cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) -} - -/// Fails when the renderer still draws text under an applied redaction mark. -/// -/// Redaction decides what lies under a mark from the positions its own interpreter computes, and -/// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap -/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the -/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is -/// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its -/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps -/// the document as it was. -fn shown_under_redactions( - after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], - overlays: &[pdfcraft_cos::ObjRef], - pages: &[pdfcraft_render::PageInfo], - password: Option<&str>, -) -> Result<(), EditError> { - use pdfcraft_cos::Object; - use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); - marked.sort_unstable(); - marked.dedup(); - if marked.is_empty() { - return Ok(()); - } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); - for &pi in &marked { - let Some(page) = model.get(pi) else { continue }; - let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); - shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; - } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; - let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); - let mut residue = 0; - for &pi in &marked { - let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; - // A page that cannot be read back cannot be confirmed clean. - let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); - for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { - let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); - let area = (x1 - x0) * (y1 - y0); - if area.is_nan() || area <= 0.0 { - continue; - } - // Redaction itself takes any glyph a fifth under a mark; one that is half under it - // and still drawn was not where redaction thought it was. - let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; - if rects.iter().any(under) { - residue += 1; - } - } - } - if residue > 0 { - return Err(RedactError::Residue(residue).into()); - } - Ok(()) -} - fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d06faefc8b..a30d81b74b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; use pdfcraft_render::{OpenError, ReadingColors}; From 748b20abb91ee2ad8a5e9cdc23fdbc6aceb672ff Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:58 +0100 Subject: [PATCH 19/33] refactor(engine): move the XFA form helpers to xfa_form.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 299 +--------------------------------- crates/engine/src/xfa_form.rs | 297 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 crates/engine/src/xfa_form.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15f6629e07..56677ed5d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod text_fonts; mod text_preview; pub mod word_count; pub mod xfa; +mod xfa_form; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; @@ -188,7 +189,6 @@ fn record_link_scan() { pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; -use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; @@ -197,6 +197,10 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, +}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -465,299 +469,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// The form's fields as the XFA data layer wants them. -fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { - use pdfcraft_forms::FieldKind as K; - pdfcraft_forms::fields(doc) - .into_iter() - .map(|f| { - let data = match f.kind { - K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), - K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), - K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), - K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, - }; - pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } - }) - .collect() -} - -/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not -/// be written. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// Most XFA warnings kept per document. -const MAX_XFA_WARNINGS: usize = 50; - -/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). -fn note_warnings(list: &mut Vec, new: &[String]) { - for w in new { - if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { - list.push(w.clone()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -pub(crate) fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), - pdfcraft_xfa::image::Kind::Png => Some("image/png"), - pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), - pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), - pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns -/// the names of the fields that changed. -fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { - let data = xfa_field_data(doc); - let fields = pdfcraft_forms::fields(doc); - // The first field of each name, as a lookup by name would find it. - let mut by_name = std::collections::HashMap::new(); - for f in &fields { - by_name.entry(f.name.as_str()).or_insert(f); - } - let mut updates = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = by_name.get(name.as_str()) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), - pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), - pdfcraft_xfa::FieldData::None => None, - }; - if let Some(v) = new { - updates.push((name, v)); - } - } - // All at once: one by one, each value would read the whole form again, which took minutes - // on a form with thousands of fields. - let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; - Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 0000000000..5ceaba1cd4 --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +} From 71dccdda505bf891c147d25ca985adcaadea0aa5 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:07:23 +0100 Subject: [PATCH 20/33] refactor(engine): move the comment list and summary to comments.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/comments.rs | 163 +++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 165 +--------------------------------- 2 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 crates/engine/src/comments.rs diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs new file mode 100644 index 0000000000..eee799ed2f --- /dev/null +++ b/crates/engine/src/comments.rs @@ -0,0 +1,163 @@ +//! The comment list and its summary. + +/// The comment list, read from the object graph (as `inspect` lists comments). +pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { + let mut list: Vec = pdfcraft_annot::summaries(doc) + .into_iter() + .map(|s| pdfcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + parent: s.parent, + grouped: s.grouped, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + locked: s.locked, + intent: s.intent, + fill_sign: s.fill_sign, + no_rotate: s.no_rotate, + points: s.points, + }) + .collect(); + pdfcraft_render::link_replies(&mut list); + list +} + +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { + match a.intent.as_deref() { + Some("FreeTextCallout") => "Callout", + Some("PolygonCloud") => "Cloud", + Some("FreeTextTypeWriter") => "Typewriter", + _ => comment_type_name(&a.subtype), + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + write_replies(out, children, r, depth + 1); + } + } + let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + write_replies(&mut out, &children, a, 0); + out.push('\n'); + } + out +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56677ed5d3..a809b91021 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; +mod comments; pub mod compare; mod copy_image; pub mod dates; @@ -187,6 +188,8 @@ fn record_link_scan() { }); } +use comments::comment_list; +pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; @@ -349,36 +352,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// The comment list, read from the object graph (as `inspect` lists comments). -fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - let mut list: Vec = pdfcraft_annot::summaries(doc) - .into_iter() - .map(|s| pdfcraft_render::Annotation { - page: s.page, - subtype: s.subtype, - author: s.author, - contents: s.contents, - modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), - name: s.name, - in_reply_to: s.in_reply_to, - parent: s.parent, - grouped: s.grouped, - rect: s.rect, - color: s.color, - index: s.index, - state: s.state, - quads: s.quads, - locked: s.locked, - intent: s.intent, - fill_sign: s.fill_sign, - no_rotate: s.no_rotate, - points: s.points, - }) - .collect(); - pdfcraft_render::link_replies(&mut list); - list -} - /// Seconds to add to UTC for local time (0 where unknown). fn local_utc_offset() -> i64 { #[cfg(not(target_arch = "wasm32"))] @@ -538,138 +511,6 @@ pub struct OptimizeReport { pub discarded: Vec<(Hidden, usize)>, } -/// Order of Summarize Comments. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SummarySort { - #[default] - Page, - Author, - Date, - Type, -} - -impl SummarySort { - pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; - pub fn name(self) -> &'static str { - match self { - SummarySort::Page => "Page", - SummarySort::Author => "Author", - SummarySort::Date => "Date", - SummarySort::Type => "Type", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) - } -} - -/// Acrobat's name for a comment type, as comment lists show it. -pub fn comment_type_name(subtype: &str) -> &str { - match subtype { - "Text" => "Sticky Note", - "FreeText" => "Text Box", - "Highlight" => "Highlight", - "Underline" => "Underline", - "StrikeOut" => "Strikethrough", - "Squiggly" => "Squiggly", - "Square" => "Rectangle", - "Circle" => "Oval", - "Line" => "Line", - "Ink" => "Pencil", - "Polygon" => "Polygon", - "PolyLine" => "Polygonal Line", - "Stamp" => "Stamp", - "Caret" => "Inserted Text", - "FileAttachment" => "File Attachment", - "Sound" => "Sound", - "Redact" => "Redaction", - other => other, - } -} - -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. -pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { - match a.intent.as_deref() { - Some("FreeTextCallout") => "Callout", - Some("PolygonCloud") => "Cloud", - Some("FreeTextTypeWriter") => "Typewriter", - _ => comment_type_name(&a.subtype), - } -} - -/// The text of a comment summary: one block per comment (replies indented under it), with a -/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are -/// pop-ups; the number is the comment's position on its page. -pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { - use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); - // Each reply under the annotation its `/IRT` points at, in document order, gathered once. - type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; - let mut children = Children::new(); - for r in all.iter().filter(|r| r.state.is_none()) { - if let Some(parent) = r.parent { - children.entry(parent).or_default().push(r); - } - } - // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the - // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { - use std::fmt::Write; - if depth > 64 { - return; - } - for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { - let indent = " ".repeat(depth + 1); - let _ = - writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, "{indent}{line}"); - } - write_replies(out, children, r, depth + 1); - } - } - let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); - let mut last = usize::MAX; - let mut n = 0; - for a in &top { - if a.page != last { - last = a.page; - n = 0; - } - n += 1; - numbered.push((n, a)); - } - match sort { - SummarySort::Page => {} - SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), - SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), - SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), - } - let mut out = format!("Summary of Comments on {name}\n\n"); - if numbered.is_empty() { - out.push_str("This document has no comments.\n"); - return out; - } - let mut heading = usize::MAX; - for (n, a) in numbered { - if sort == SummarySort::Page && a.page != heading { - heading = a.page; - let _ = writeln!(out, "Page: {}", a.page + 1); - } - let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); - if sort != SummarySort::Page { - let _ = write!(out, " Page: {}", a.page + 1); - } - let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); - if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { - let _ = writeln!(out, "{c}"); - } - write_replies(&mut out, &children, a, 0); - out.push('\n'); - } - out -} - #[cfg(test)] mod tests; From 20974e7822ef8a9c12cfe3d9de4bcbd6c47df291 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:01 +0100 Subject: [PATCH 21/33] refactor(engine): move the combine and page-source helpers to sources.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 49 +++------------------------------- crates/engine/src/sources.rs | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/sources.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a809b91021..274c00456b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,6 +44,7 @@ mod scan_fields; mod session; pub mod signature_image; pub mod signature_scan; +mod sources; mod stamp_preview; pub mod text_fonts; mod text_preview; @@ -200,6 +201,8 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +use sources::{open_source, open_source_with}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, @@ -270,17 +273,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). -pub type CombineSource = (String, Arc>, Option); - -/// 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>, - pub renamed_fields: Vec<(String, String)>, -} - /// A bookmark as the bookmark list shows it. #[derive(Clone, Debug, PartialEq)] pub struct BookmarkRow { @@ -407,41 +399,6 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { }) } -/// Parse another PDF to copy pages from. -pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { - open_source_with(name, bytes, None) -} - -/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. -fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { - source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) -} - -fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), - Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), - Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), - Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), - Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), - } -} - -/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not -/// why: what Combine would refuse it for, so the Combine files list can say so before Combine -/// is pressed. The owner (permissions) password lifts the restriction on copying pages. -pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { - source_document(bytes, password).map(|_| ()) -} - -/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever -/// its permissions allow; `None` when it can't be read. -pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { - let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; - pdfcraft_organize::page_count(&doc).ok() -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs new file mode 100644 index 0000000000..3c8083162e --- /dev/null +++ b/crates/engine/src/sources.rs @@ -0,0 +1,51 @@ +//! Other files to combine with or copy pages from: opening them, and why one can't be used. + +use std::sync::Arc; + +use crate::{EditError, SourceProblem}; + +/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). +pub type CombineSource = (String, Arc>, Option); + +/// 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>, + pub renamed_fields: Vec<(String, String)>, +} + +/// Parse another PDF to copy pages from. +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { + open_source_with(name, bytes, None) +} + +/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. +pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { + source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + +fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { + Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not +/// why: what Combine would refuse it for, so the Combine files list can say so before Combine +/// is pressed. The owner (permissions) password lifts the restriction on copying pages. +pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { + source_document(bytes, password).map(|_| ()) +} + +/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever +/// its permissions allow; `None` when it can't be read. +pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { + let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; + pdfcraft_organize::page_count(&doc).ok() +} From 4972a4e1ebb02fcda1225f2082eba73a476db4d6 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:14 +0100 Subject: [PATCH 22/33] refactor(engine): move the bookmark list rows to bookmarks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/bookmarks.rs | 61 ++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 63 ++-------------------------------- 2 files changed, 64 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/bookmarks.rs diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs new file mode 100644 index 0000000000..deaece22e4 --- /dev/null +++ b/crates/engine/src/bookmarks.rs @@ -0,0 +1,61 @@ +//! The bookmark list. + +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 274c00456b..fa18e5a66d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod autotag; +mod bookmarks; pub mod build_info; pub mod camera; pub mod camera_clean; @@ -189,6 +190,8 @@ fn record_link_scan() { }); } +use bookmarks::outline_rows; +pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; @@ -273,66 +276,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A bookmark as the bookmark list shows it. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkRow { - /// Child indices from the top level, 0-based, as the edit functions take them. - pub path: Vec, - pub title: String, - /// Shown expanded (a positive `/Count`). - pub open: bool, - /// The 0-based page it goes to. - pub page: Option, - /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). - pub view: pdfcraft_render::DestView, - /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). - pub color: Option<[f32; 3]>, - pub italic: bool, - pub bold: bool, -} - -/// One page of the bookmark list, in outline order. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkPage { - pub rows: Vec, - /// Where the next page starts, or `None` at the end. - pub next: Option, - /// Set only when the document's structure could not be read for editing: its listing then - /// comes from the viewer's copy of the outline, which is shorter than the file's. - pub truncated: bool, -} - -/// The rows of a viewer outline, in outline order, with their paths. -fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { - let mut rows = Vec::new(); - // One entry per open level: its items, and how many of them have been listed. - let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; - while let Some(&(list, next)) = levels.last() { - let Some(item) = list.get(next) else { - levels.pop(); - continue; - }; - if let Some(top) = levels.last_mut() { - top.1 += 1; - } - let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { - path, - title: item.title.clone(), - open: item.open, - page: item.page, - view: item.view, - color: item.color, - italic: item.italic, - bold: item.bold, - }); - if !item.children.is_empty() { - levels.push((&item.children, 0)); - } - } - rows -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { From 8473403a5dce297e9ca0abdc6bde74af045e07de Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:38 +0100 Subject: [PATCH 23/33] refactor(engine): move layer visibility to layers.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layers.rs | 60 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 56 ++-------------------------------- crates/engine/src/tests.rs | 2 +- 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 crates/engine/src/layers.rs diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs new file mode 100644 index 0000000000..c2f4f63714 --- /dev/null +++ b/crates/engine/src/layers.rs @@ -0,0 +1,60 @@ +//! Layer (optional content) visibility: what the view shows and what printing writes. + +use std::sync::Arc; + +use pdfcraft_render::{Layer, LayerOp, RenderPool}; + +use crate::{Document, render_threads}; + +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + +/// Render `doc` with the layer visibility in its `info.layers`. +pub(crate) fn use_layer_choices(doc: &mut Document) { + let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); + doc.config.layers = Arc::new(overrides); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); +} + +/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the +/// state the changes before it left. Returns whether any layer ended up changed. +pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let before: Vec = layers.iter().map(|l| l.visible).collect(); + let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); + for &(op, ocg) in changes { + let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; + let on = match op { + LayerOp::On => true, + LayerOp::Off => false, + LayerOp::Toggle => !layer.visible, + }; + layer.visible = on; + // Turning a layer off leaves the rest of its groups alone. + if on && preserve_rb { + for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { + if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { + l.visible = false; + } + } + } + } + layers.iter().zip(before).any(|(l, was)| l.visible != was) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fa18e5a66d..7a93fc87b0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod export; pub mod field_table; mod history; pub mod js; +mod layers; pub mod links; pub mod ocr; mod open; @@ -199,8 +200,8 @@ use document::{display_bytes, info_fields, recovered_render_password, same_pages pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; +use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -223,59 +224,6 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } -/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. -fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { - use pdfcraft_cos::{Dict, ObjRef, Object}; - let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; - let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; - let Some(properties) = root.get(b"OCProperties").cloned() else { return }; - let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; - let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); - let refs = - |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); - config.set(b"BaseState".to_vec(), Object::name("ON")); - config.set(b"ON".to_vec(), refs(true)); - config.set(b"OFF".to_vec(), refs(false)); - properties.set(b"D".to_vec(), Object::Dict(config)); - root.set(b"OCProperties".to_vec(), Object::Dict(properties)); - cos.set(root_ref, Object::Dict(root)); -} - -/// Render `doc` with the layer visibility in its `info.layers`. -fn use_layer_choices(doc: &mut Document) { - let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); - doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); -} - -/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the -/// state the changes before it left. Returns whether any layer ended up changed. -fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let before: Vec = layers.iter().map(|l| l.visible).collect(); - let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); - for &(op, ocg) in changes { - let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; - let on = match op { - LayerOp::On => true, - LayerOp::Off => false, - LayerOp::Toggle => !layer.visible, - }; - layer.visible = on; - // Turning a layer off leaves the rest of its groups alone. - if on && preserve_rb { - for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { - if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { - l.visible = false; - } - } - } - } - layers.iter().zip(before).any(|(l, was)| l.visible != was) -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a30d81b74b..c622af21b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; -use pdfcraft_render::{OpenError, ReadingColors}; +use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From 94b611081941737961d30dcef3e6b5fae75ad1af Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:11 +0100 Subject: [PATCH 24/33] refactor(engine): move placed pictures and mark sources to pictures.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 57 ++------------------------------- crates/engine/src/pictures.rs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/pictures.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a93fc87b0..ea9c267ae0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod pictures; pub mod print_raster; pub mod printcheck; mod protection; @@ -202,6 +203,8 @@ pub use error::{EditError, SourceProblem}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use pictures::MarkFile; +use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -295,60 +298,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` -/// (0-based) of a PDF. -#[derive(Clone, Debug, PartialEq)] -pub struct MarkFile { - pub name: String, - pub bytes: Arc>, - pub page: usize, -} - -/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -pub(crate) fn embed_image( - doc: &mut pdfcraft_cos::Document, - name: &str, - bytes: &[u8], -) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, bytes) -} - -/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its -/// shape in a box of any other shape. A box that is already that shape (to within rounding) is -/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite -/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { - if !rect.iter().all(|v| v.is_finite()) { - return rect; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); - let usable = |v: f64| v.is_finite() && v > 0.0; - if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { - return rect; - } - let k = (bw / w).min(bh / h); - let (fw, fh) = (w * k, h * k); - if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { - return rect; - } - let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); - [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] -} - -/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { - let head = &f.bytes[..f.bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { - let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; - let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) - } else { - let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) - } -} - /// What Optimize PDF did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OptimizeReport { diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs new file mode 100644 index 0000000000..b29941720a --- /dev/null +++ b/crates/engine/src/pictures.rs @@ -0,0 +1,59 @@ +//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds. + +use std::sync::Arc; + +use crate::EditError; + +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +pub(crate) fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, bytes) +} + +/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its +/// shape in a box of any other shape. A box that is already that shape (to within rounding) is +/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite +/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { + if !rect.iter().all(|v| v.is_finite()) { + return rect; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { + return rect; + } + let k = (bw / w).min(bh / h); + let (fw, fh) = (w * k, h * k); + if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { + return rect; + } + let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); + [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) + } +} From fc6072154d8321ff48cb802189b1bc856bc958be Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:23 +0100 Subject: [PATCH 25/33] refactor(engine): move the Fill & Sign geometry helpers to fill_sign.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fill_sign.rs | 47 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 45 ++------------------------------ 2 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/fill_sign.rs diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs new file mode 100644 index 0000000000..08739addfa --- /dev/null +++ b/crates/engine/src/fill_sign.rs @@ -0,0 +1,47 @@ +//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them. + +use crate::{Shape, script_outline, signature_image}; + +/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user +/// space, vertically centred) and `height` points tall. `None` for text with no outlines. +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { + let o = script_outline(text); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { + return None; + } + let k = height / span; + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); + Some(Shape::TypedSignature { rect, contours }) +} + +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + +/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types +/// it. `None` for text with no outlines. +pub fn typed_signature_graphic(text: &str) -> Option { + let o = script_outline(text); + o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ea9c267ae0..25428ebf67 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ mod edit; mod error; pub mod export; pub mod field_table; +mod fill_sign; mod history; pub mod js; mod layers; @@ -84,49 +85,6 @@ pub enum ImageEdit { Delete, } -/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user -/// space, vertically centred) and `height` points tall. `None` for text with no outlines. -/// -/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): -/// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { - let o = script_outline(text); - let [left, bottom, right, top] = o.bounds(); - let span = (top - bottom).max(0.1); - let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { - return None; - } - let k = height / span; - let rect = signature_image::upright_box(rotation, at, width * k, height)?; - // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box - // is at this fraction of the user-space box. Exact for an unturned page. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); - Some(Shape::TypedSignature { rect, contours }) -} -/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). -pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - [at[0] + a * right + c * up, at[1] + b * right + d * up] -} - -/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, -/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a -/// non-finite point. -pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { - signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) -} - -/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types -/// it. `None` for text with no outlines. -pub fn typed_signature_graphic(text: &str) -> Option { - let o = script_outline(text); - o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) -} - pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; @@ -200,6 +158,7 @@ pub use document::{Document, TextNotice, display_bytes_for, render_password_for} use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; +pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; From 8ca4bf641f05fb96d55dbbfc28292d8943681ce0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:38 +0100 Subject: [PATCH 26/33] refactor(engine): move the page filters to page_filter.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 42 ++------------------------------ crates/engine/src/page_filter.rs | 41 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/page_filter.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25428ebf67..825494f26f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod page_filter; mod pictures; pub mod print_raster; pub mod printcheck; @@ -162,6 +163,7 @@ pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, type pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::MarkFile; use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; @@ -269,43 +271,3 @@ pub struct OptimizeReport { #[cfg(test)] mod tests; - -/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and -/// orientation (as displayed). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageParity { - #[default] - Both, - Even, - Odd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrientation { - #[default] - Both, - Landscape, - Portrait, -} - -/// The pages of `pages` (0-based) that pass the filters. -pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { - pages - .iter() - .copied() - .filter(|p| match parity { - PageParity::Both => true, - // Page numbers are 1-based: index 1 is page 2, an even page. - PageParity::Even => p % 2 == 1, - PageParity::Odd => p % 2 == 0, - }) - .filter(|p| { - let Some(pg) = info.pages.get(*p) else { return false }; - match orientation { - PageOrientation::Both => true, - PageOrientation::Landscape => pg.width > pg.height, - PageOrientation::Portrait => pg.width <= pg.height, - } - }) - .collect() -} diff --git a/crates/engine/src/page_filter.rs b/crates/engine/src/page_filter.rs new file mode 100644 index 0000000000..aaedfbd65c --- /dev/null +++ b/crates/engine/src/page_filter.rs @@ -0,0 +1,41 @@ +//! Page filters: even or odd page numbers, and orientation as displayed. + +/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and +/// orientation (as displayed). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageParity { + #[default] + Both, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrientation { + #[default] + Both, + Landscape, + Portrait, +} + +/// The pages of `pages` (0-based) that pass the filters. +pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { + pages + .iter() + .copied() + .filter(|p| match parity { + PageParity::Both => true, + // Page numbers are 1-based: index 1 is page 2, an even page. + PageParity::Even => p % 2 == 1, + PageParity::Odd => p % 2 == 0, + }) + .filter(|p| { + let Some(pg) = info.pages.get(*p) else { return false }; + match orientation { + PageOrientation::Both => true, + PageOrientation::Landscape => pg.width > pg.height, + PageOrientation::Portrait => pg.width <= pg.height, + } + }) + .collect() +} From 51fbed293bbc00f7470f5a09de44fa4d715cd665 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:57 +0100 Subject: [PATCH 27/33] refactor(engine): move the test-only hooks to test_hooks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 52 +++------------------------------ crates/engine/src/test_hooks.rs | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/test_hooks.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 825494f26f..ebd0b5c7a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,8 @@ pub mod signature_image; pub mod signature_scan; mod sources; mod stamp_preview; +#[cfg(test)] +mod test_hooks; pub mod text_fonts; mod text_preview; pub mod word_count; @@ -103,54 +105,6 @@ pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; -#[cfg(test)] -thread_local! { - static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; - static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -fn panic_on_refresh(count: usize) { - REFRESH_PANICS.with(|n| n.set(count)); -} - -#[cfg(test)] -fn check_refresh_panic() { - REFRESH_PANICS.with(|n| { - let rem = n.get(); - if rem > 0 { - n.set(rem - 1); - panic!("injected refresh panic"); - } - }); -} - -#[cfg(test)] -fn reset_secondary_scan_counts() { - SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); -} - -#[cfg(test)] -fn secondary_scan_counts() -> (usize, usize) { - SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) -} - -#[cfg(test)] -fn record_marker_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers + 1, links)); - }); -} - -#[cfg(test)] -fn record_link_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers, links + 1)); - }); -} - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -171,6 +125,8 @@ pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; use sources::{open_source, open_source_with}; +#[cfg(test)] +use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs new file mode 100644 index 0000000000..d6e504599e --- /dev/null +++ b/crates/engine/src/test_hooks.rs @@ -0,0 +1,42 @@ +//! Test-only hooks: injected refresh panics and counts of the secondary page scans. + +thread_local! { + static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; + static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn panic_on_refresh(count: usize) { + REFRESH_PANICS.with(|n| n.set(count)); +} + +pub(crate) fn check_refresh_panic() { + REFRESH_PANICS.with(|n| { + let rem = n.get(); + if rem > 0 { + n.set(rem - 1); + panic!("injected refresh panic"); + } + }); +} + +pub(crate) fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +pub(crate) fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +pub(crate) fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +pub(crate) fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} From dc224884cb9c50307ef589b683405d17ee3be4e3 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:10:38 +0100 Subject: [PATCH 28/33] refactor(engine): gather lib.rs's imports and re-exports under the module list Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 77 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebd0b5c7a1..78e5ea1eb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,11 @@ pub mod word_count; pub mod xfa; mod xfa_form; +use std::sync::Arc; + +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; @@ -66,45 +71,6 @@ pub use text_preview::TextEditPreview; pub use reexports::*; -/// A change to an existing page image. -#[derive(Clone, Debug, PartialEq)] -pub enum ImageEdit { - /// Move and resize it to this box (user space). - Move([f64; 4]), - /// Quarter turns clockwise about its centre. - Rotate(i32), - /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). - RotateBy(f64), - Flip { - horizontal: bool, - }, - /// Show only this box of it (user space), the pixels staying where they are. - Crop([f64; 4]), - /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. - Replace { - name: String, - bytes: Arc>, - }, - Delete, -} - -pub use links::{ - MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, -}; - -/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a -/// filled-in PDF form), as CSV with one row per file. -pub fn merge_data_files(files: &[(String, Vec)]) -> Result { - let rows = - files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; - Ok(pdfcraft_xfdf::merge_csv(&rows)) -} - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub type SplitPart = (usize, usize, Arc>); - -use std::sync::Arc; - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -132,6 +98,39 @@ use xfa_form::{ undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, }; +/// A change to an existing page image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageEdit { + /// Move and resize it to this box (user space). + Move([f64; 4]), + /// Quarter turns clockwise about its centre. + Rotate(i32), + /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). + RotateBy(f64), + Flip { + horizontal: bool, + }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), + /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. + Replace { + name: String, + bytes: Arc>, + }, + Delete, +} + +/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a +/// filled-in PDF form), as CSV with one row per file. +pub fn merge_data_files(files: &[(String, Vec)]) -> Result { + let rows = + files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; + Ok(pdfcraft_xfdf::merge_csv(&rows)) +} + +/// One file produced by a split: (1-based first page, last page, PDF bytes). +pub type SplitPart = (usize, usize, Arc>); + /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); From 00596b69a6c4cf7a46d83f3ed9723815d55ae4a0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:12:15 +0100 Subject: [PATCH 29/33] refactor(engine): keep the moved docs' intra-doc links resolving (document, fill_sign) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 6 +++--- crates/engine/src/fill_sign.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 0c85f51af6..7023caa55e 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,7 +59,7 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, } @@ -440,7 +440,7 @@ impl Document { } /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. + /// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions. pub fn can_unlock(&self) -> bool { self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) } @@ -629,7 +629,7 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs index 08739addfa..1bd8c68d05 100644 --- a/crates/engine/src/fill_sign.rs +++ b/crates/engine/src/fill_sign.rs @@ -7,7 +7,7 @@ use crate::{Shape, script_outline, signature_image}; /// /// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): /// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across. pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { let o = script_outline(text); let [left, bottom, right, top] = o.bounds(); From 323f569b37b2982b73bde3bddc3453479da56dbe Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:14:58 +0100 Subject: [PATCH 30/33] docs: point form-performance at the engine's session.rs and edit.rs Co-Authored-By: Claude Opus 5.5 --- docs/form-performance.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/form-performance.md b/docs/form-performance.md index c00adf0913..1ef72dfc6d 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b - `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. -- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo. - `run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`. +- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and + undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an + engine `JsRunner`. - `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes. There is no dependency graph or dependency-based filtering: dynamic references and script @@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim. ## Revision history +- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply` + and `run_edit` moved when the engine's `lib.rs` was split. - 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions and all performance changes, and repeat focused, scoped, benchmark and headless validation. From fc578a0b02e9a577b4bdb9e7cc2d9113623e9f4f Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:36:22 -0600 Subject: [PATCH 31/33] chore(deps): bump 11 safe dependency updates --- Cargo.lock | 349 +++++++++++++---------------------- apps/pdfcraft-cli/Cargo.toml | 2 +- crates/automation/Cargo.toml | 2 +- crates/sign/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/xfa/Cargo.toml | 2 +- 6 files changed, 138 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd3e1930fd..54be313cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,21 +215,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -443,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -454,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -489,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -508,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -519,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -576,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -645,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -736,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -1164,7 +1192,7 @@ dependencies = [ "dispatch2", "egui", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1175,7 +1203,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd0 dependencies = [ "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", @@ -1473,7 +1501,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1594,7 +1622,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] @@ -1617,7 +1645,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1689,7 +1717,7 @@ dependencies = [ "egui", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", @@ -1920,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2090,7 +2118,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2409,7 +2437,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3241,10 +3269,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3346,7 +3374,7 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3551,9 +3579,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3582,7 +3610,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3632,7 +3660,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3643,7 +3671,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3699,7 +3727,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3710,7 +3738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3746,7 +3774,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3770,7 +3798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3815,7 +3843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3913,7 +3941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3926,7 +3954,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3940,7 +3968,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3954,7 +3982,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -4065,7 +4093,7 @@ dependencies = [ name = "pdfcraft-automation" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4079,7 +4107,7 @@ dependencies = [ "png 0.18.1", "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] @@ -4087,7 +4115,7 @@ dependencies = [ name = "pdfcraft-cli" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4151,7 +4179,7 @@ dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] @@ -4398,7 +4426,7 @@ version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", @@ -4423,7 +4451,7 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] @@ -4437,7 +4465,7 @@ name = "pdfcraft-ui-egui" version = "0.14.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", @@ -4453,7 +4481,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4498,13 +4526,13 @@ dependencies = [ name = "pdfcraft-xfa" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4750,9 +4778,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap", @@ -4852,15 +4880,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4944,16 +4963,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5027,7 +5047,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -5051,7 +5071,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5075,16 +5095,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -5096,31 +5106,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5138,11 +5129,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5169,7 +5160,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5362,7 +5353,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5601,7 +5592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5633,9 +5624,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5671,7 +5662,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5843,9 +5834,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5942,9 +5933,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6100,7 +6091,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6555,10 +6546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7012,9 +7003,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", "flate2", @@ -7029,9 +7020,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -7453,7 +7444,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7609,7 +7600,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7670,7 +7661,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7791,7 +7782,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -7800,16 +7791,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7827,31 +7809,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7869,96 +7834,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -8051,9 +7968,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8235,7 +8152,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 7b5f9a82f3..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" [dev-dependencies] # Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index bed579ab84..a6e43de1f0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } [features] diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 4f1f0d4094..3c0f207a98 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -18,7 +18,7 @@ pdfcraft-model = { workspace = true } thiserror.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } -base64 = "0.22" +base64 = "0.23" sha1 = "0.11" sha2 = "0.11" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c096254995..5279f56248 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,7 +33,7 @@ bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" -base64 = "0.22" +base64 = "0.23" # Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns # display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index dcec1e75bc..d917018057 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } roxmltree = "0.20" -base64 = "0.22" +base64 = "0.23" png = "0.18" sha2 = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] } From 01b696df2c79636ff6ea737e6b02eff634d99f6c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:11:45 -0400 Subject: [PATCH 32/33] test(ui): CJK interface tests skip without craft-fonts (#4706) * test(ui): CJK interface tests skip without craft-fonts * test(ui): a build that requires craft-fonts never skips the CJK interface tests --- crates/ui-egui/tests/it/combine_grid.rs | 4 +++ crates/ui-egui/tests/it/commands.rs | 3 ++ crates/ui-egui/tests/it/content.rs | 6 ++++ crates/ui-egui/tests/it/control.rs | 21 +++++++++++++ crates/ui-egui/tests/it/date_format.rs | 7 +++++ crates/ui-egui/tests/it/editing.rs | 6 ++++ crates/ui-egui/tests/it/pickers.rs | 3 ++ crates/ui-egui/tests/it/support/mod.rs | 41 +++++++++++++++++++++++-- 8 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index c6f2e3e721..0339e0b6bb 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -530,6 +530,10 @@ fn combine_follows_the_order_shown_in_the_grid() { #[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") + { + return; + } let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index ff82820148..15c23c6296 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -623,6 +623,9 @@ fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() { #[test] fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() { + if crate::support::skip_without_glyphs("palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted", "en", "你") { + return; + } let mut h = harness(); let mut interruptions = Vec::new(); h.state_mut().run_command("view.palette"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0353a7d2b3..4a7927f692 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -335,6 +335,9 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { /// written to the page as `?` (or lost). #[test] fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { + if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") { + return; + } let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); @@ -372,6 +375,9 @@ fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { /// is left blocking Save out of sight. #[test] fn a_kept_draft_goes_with_its_deleted_page() { + if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") { + return; + } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index e18c7bf47a..fe96bb5951 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -504,6 +504,9 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(documents[0]["dirty"], true); let commands = ok(&mut h, &c, "ui.commands", json!({})); for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] { + if crate::support::skip_without_glyphs(&format!("language_switch_preserves_document_and_command_ids ({code})"), code, "") { + continue; + } ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code })); h.run_steps(2); let state = ok(&mut h, &c, "ui.state", json!({})); @@ -908,6 +911,9 @@ fn state_and_view_options() { #[test] fn japanese_controls_and_search_keep_command_ids() { + if crate::support::skip_without_glyphs("japanese_controls_and_search_keep_command_ids", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); @@ -1002,6 +1008,9 @@ fn ukrainian_preferences_and_search_keep_command_ids() { #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { + if crate::support::skip_without_glyphs("preferences_menu_and_shortcut_allow_switching_interface_languages", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); @@ -1044,6 +1053,9 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { #[test] fn japanese_dialogs_errors_and_custom_action_names() { + if crate::support::skip_without_glyphs("japanese_dialogs_errors_and_custom_action_names", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"})); for (dialog, label) in [ @@ -1084,6 +1096,9 @@ fn japanese_dialogs_errors_and_custom_action_names() { #[test] fn simplified_chinese_about_tabs_and_credit_controls() { + if crate::support::skip_without_glyphs("simplified_chinese_about_tabs_and_credit_controls", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1113,6 +1128,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() { #[test] fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { + if crate::support::skip_without_glyphs("simplified_chinese_signature_prompts_and_errors_keep_document_state", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1721,6 +1739,9 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { #[test] fn control_quit_retains_blocked_content_typing_after_cancel() { + if crate::support::skip_without_glyphs("control_quit_retains_blocked_content_typing_after_cancel", "en", "Keep 世界") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id":"edit.text"})); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/date_format.rs b/crates/ui-egui/tests/it/date_format.rs index 0606e021de..c2dadb28d3 100644 --- a/crates/ui-egui/tests/it/date_format.rs +++ b/crates/ui-egui/tests/it/date_format.rs @@ -133,6 +133,13 @@ fn month_names_follow_the_date_language_or_else_the_interface_language() { #[test] fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() { + if crate::support::skip_without_glyphs( + "a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first", + "en", + "日曜日月曜日火曜日水曜日木曜日金曜日土曜日", + ) { + return; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..0895a8b5be 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1087,6 +1087,9 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() { let size = egui::vec2(820.0, 520.0); for name in names { let start: String = name.chars().take(10).collect(); + if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) { + continue; + } let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); @@ -3575,6 +3578,9 @@ fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() { /// as a form field does; the editor used to close with the error and the typing was lost. #[test] fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() { + if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") { + return; + } let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 6aa5515527..f9e46c5f2f 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -963,6 +963,9 @@ impl Drop for AttachmentDir { #[test] fn attachment_save_preserves_bytes_metadata_and_selected_destination() { + if crate::support::skip_without_glyphs("attachment_save_preserves_bytes_metadata_and_selected_destination", "en", "user chosen 資料.bin") { + return; + } let dir = AttachmentDir::new(); let chosen = dir.0.join("user chosen 資料.bin"); let original = attachment_fixture(); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index aa96ab20dc..174ec5cc0b 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,12 @@ //! State-based waits for native preparation, which completes between UI frames, and pointer -//! input in the frames the platform delivers it in ([`input`]). +//! input in the frames the platform delivers it in ([`input`]), and the skip for tests whose text +//! the interface fonts cannot draw ([`skip_without_glyphs`]). +use std::collections::BTreeSet; + +use egui::FontId; +use egui::epaint::text::{Fonts, TextOptions}; use egui_kittest::Harness; -use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme}; pub mod input; @@ -45,3 +50,35 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl h.state().toast ); } + +/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it +/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses +/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional +/// craft-fonts build input or a face installed on this machine. Prints the skip line. +/// +/// The fonts are those the app installs for `language` and for the default CJK preference: a +/// switch to Chinese installs its fonts for the next frame, so the first frame in Chinese draws +/// with the fonts of the language before it. +/// +/// A build that requires craft-fonts (`CRAFT_FONTS_REQUIRED=1`, as the CI gate sets) never skips: +/// a missing glyph there is a failure. +pub fn skip_without_glyphs(name: &str, language: &str, text: &str) -> bool { + if std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1") { + return false; + } + let catalog = i18n::LANGUAGES.iter().find(|l| l.code == language).map_or("", |l| l.source); + let translations = catalog.lines().filter(|l| !l.starts_with('#')).filter_map(|l| l.split('\t').nth(2)); + let chars: BTreeSet = std::iter::once(text).chain(translations).flat_map(str::chars).filter(|c| !c.is_control()).collect(); + let cjk = theme::CjkPreference::for_language(language); + let missing = [theme::CjkPreference::Default, cjk].into_iter().find_map(|cjk| { + let defs = theme::installed_font_definitions(cjk); + let families: Vec = defs.families.keys().map(|f| FontId::new(13.0, f.clone())).collect(); + let mut fonts = Fonts::new(TextOptions::default(), defs); + chars.iter().copied().find(|c| families.iter().any(|id| !fonts.has_glyphs(id, &c.to_string()))) + }); + let Some(missing) = missing else { + return false; + }; + eprintln!("skipping {name}: no interface font draws {missing:?} (set CRAFT_FONTS_DIR to a craft-fonts checkout with a face for it)"); + true +} From 64e0868b844e0230027a493c90d6e4bdc7af086f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:46 -0400 Subject: [PATCH 33/33] feat(ui): menus from craft-menu, native on macOS (#4752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): menus from craft-menu, native on macOS One menu definition (crates/ui-egui/src/menus.rs) built from the command registry drives the native macOS menu bar (craft-menu's muda backend) and an in-window egui bar on Windows, Linux and the web, which takes the place of the "Menu" button at the start of the top bar. - File, Edit, View, Sign, Window and Help, with separators and submenus. Edit gains Cut, Paste, Delete and Select All (they act as their keys do) and a Pages submenu; View gains a Tools submenu built from the All tools groups; Sign holds Fill & Sign and the certificate commands; Window lists the open documents. - Toggles are check items and the page layouts, side panels and themes radio items, with their state from the app. - File > Open Recent lists the recent files (file.open_recent with an index). - A menu click and its shortcut run the same dispatch, including the form-field deferral (#166) and macOS Cmd+W closing the window (#869). Dialogs and the palette grey the menus through craft-menu's gate. - Quit goes through craft-quit, so the unsaved-changes prompt runs. - craft-ui, craft-quit, craft-menu and craft-keymap pinned to storytold/craft-libs 70ade1fb. Menu state and dispatch follow #2610 by @hajdukste; the menu tree and the view and panel items follow #3870 by @RivanJarjes. * test(ui): the menu model, native dispatch and the in-window bar - Unit tests for the menu definition: every registry menu command is listed once with its registry shortcut; check and radio items follow the app; a dialog greys document commands and drops their clicks; the recent files and open documents are listed once each; a native menu click (through a fake craft-menu backend) runs its command once. - The kittests that opened the old "Menu" button use the menu bar, and the ones that look up a label the bar's titles share (Edit, File, Sign) skip the bar. - The in-window bar takes its titles' width, so the mode tabs, search and buttons keep their place in the top bar. - Edit menu clicks that stand for keys (Copy, Cut, Delete, Select All) reach this frame's input from `logic`, where test hosts see them too. * test(ui): the language switch test opens File in the menu bar Translated titles and rows carry their mnemonic ("ファイル(F)", "開く(O)…"). * feat(ui): the menu bars run Quit themselves, the bar shrinks to its titles, and AppKit's window list stays off craft-libs main edc41f6 (craft-libs#354): MenuBarUi runs Quit and Minimize, shrink_to_fit sizes the in-window bar, Key::parse reads the registry's arrow and minus glyphs, and mac::install_with(.., window_list(false)) keeps the Window menu to PdfCraft's own document list. * fix(ui): the menu command filter is an if, for clippy * test(ui): drop a needless borrow in the edit-tab helper * test(ui): two tests follow the menu bar The tests were wrong, not the app. The Preferences language test clicked the old Menu button; it opens Edit in the menu bar and its Preferences row. The prepare Actions test counted the Edit labels: the narrower menu bar leaves room for the Edit tab, so the rows' Edit buttons come third and fourth. --- Cargo.lock | 49 +- Cargo.toml | 7 +- apps/pdfcraft/Cargo.toml | 3 - apps/pdfcraft/src/main.rs | 9 +- crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/src/chrome.rs | 129 +--- crates/ui-egui/src/commands.rs | 91 +-- crates/ui-egui/src/lib.rs | 25 +- crates/ui-egui/src/menus.rs | 791 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 - crates/ui-egui/tests/it/commands.rs | 52 +- crates/ui-egui/tests/it/control.rs | 33 +- crates/ui-egui/tests/it/editing.rs | 11 +- crates/ui-egui/tests/it/marks.rs | 3 +- crates/ui-egui/tests/it/prepare.rs | 10 +- crates/ui-egui/tests/it/signing.rs | 17 +- crates/ui-egui/tests/it/support/mod.rs | 21 + crates/ui-egui/tests/it/text_select.rs | 15 +- crates/ui-egui/tests/it/theme.rs | 7 +- crates/ui-egui/tests/it/trust_signer.rs | 3 +- crates/ui-egui/tests/it/ui.rs | 10 +- crates/ui-egui/tests/it/view.rs | 18 +- 22 files changed, 990 insertions(+), 327 deletions(-) create mode 100644 crates/ui-egui/src/menus.rs diff --git a/Cargo.lock b/Cargo.lock index 54be313cc6..3c713ba5cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1171,6 +1171,29 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1187,11 +1210,10 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "dispatch2", "egui", - "muda", "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1199,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "egui", "log", @@ -1948,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3941,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4035,7 +4057,6 @@ name = "pdfcraft" version = "0.14.0" dependencies = [ "chrono", - "craft-quit", "craft-update", "eframe", "egui", @@ -4469,6 +4490,8 @@ dependencies = [ "block2 0.6.2", "bytemuck", "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", "craft-update", @@ -5071,7 +5094,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5592,7 +5615,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5662,7 +5685,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6546,10 +6569,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7661,7 +7684,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d1d5e5f6c..8eb38b29af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,12 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +# One menu definition for the native macOS menu bar and the in-window bar elsewhere. +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 15ec5d6f68..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -29,9 +29,6 @@ chrono = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ab6ebf830c..ee8ae152cc 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -429,10 +429,11 @@ fn app_creator<'a>( #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5279f56248..804b5ea22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,12 @@ workspace = true [dependencies] craft-ui = { workspace = true } +# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). +craft-menu = { workspace = true } +craft-keymap = { workspace = true } +# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window +# close also quits through `terminate:` (the Touch Bar crash, #1136). +craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } @@ -55,9 +61,6 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. -# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only. -craft-quit = { workspace = true, features = ["egui"] } # Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. # The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b977112460..b81ac2d58c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -311,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Both theme entry points use the same choices and command path. -fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - for (preference, command, label) in [ - (ThemePreference::System, "view.theme.system", "Use system setting"), - (ThemePreference::Light, "view.theme.light", "Light gray"), - (ThemePreference::Dark, "view.theme.dark", "Dark gray"), - ] { - if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() { - app.execute(command); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Appearance settings…")).clicked() { - app.dialog = Some(Dialog::Preferences); - ui.close(); - } -} - /// The right-click menu on the title bar's theme button. fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Appearance settings…")).clicked() { @@ -497,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - main_menu(app, ui); - ui.add_space(6.0); - ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); - ui.add_space(10.0); + // The menus, unless the native macOS menu bar has them. + if app.native_menu.is_none() { + app.menu_bar_ui(ui); + ui.add_space(6.0); + ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); + ui.add_space(10.0); + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { @@ -540,103 +524,6 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Keep menus usable when their contents are taller than the window. -pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); - egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); -} - -fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); - egui::Popup::menu(&resp).show(|ui| { - scroll_menu(ui, |ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); - ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); - ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); - ui.menu_button(tl!("View"), |ui| { - scroll_menu(ui, |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - scroll_menu(ui, |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); - } - } - }) - }); - }) - }); - ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); - }) - }); -} - /// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the /// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -822,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String { if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") } } -/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page. +/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display). /// Returns the command a click asks for, for the caller to run. fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a27a25ff82..26fad6f77c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut}; use crate::{ Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget, theme::{ThemeKind, ThemePreference}, - widgets, }; /// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and @@ -724,11 +723,6 @@ impl PdfCraftApp { // Buttons also own keyboard focus for Tab/Enter activation, but they must not // suppress document shortcuts. Text editors retain their own undo/redo handling. let typing = ctx.text_edit_focused(); - // A form field's editor is open on the page: its text isn't in the document until - // committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that, - // while the field has yet to see the Escape and discard its draft.) - let active = self.active_ids(); - let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect(); specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0))); for spec in specs { @@ -752,23 +746,29 @@ impl PdfCraftApp { m |= Modifiers::CTRL; } if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) { - if form_typing { - // A form field has the keyboard: let it take this frame's typing (and - // Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for - // this document only. - self.deferred_commands.push((spec.id, active.map(|(_, id)| id))); - ctx.request_repaint(); - } else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { - // macOS ⌘W closes the last window when no document tabs remain (#869). - // Route through the normal viewport-close path (and its quit guard), - // not the disabled document-only `file.close` command. - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else { - self.execute(spec.id); - } + self.dispatch_command(ctx, spec.id); } } } + + /// Run a registry command from its shortcut or a menu, which share this path. + pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) { + let active = self.active_ids(); + let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); + if form_typing { + // A form field has the keyboard: let it take this frame's typing (and Escape) first, + // so ⌘S saves what's on screen (#166). Runs next frame, for this document only. + self.deferred_commands.push((id, active.map(|(_, id)| id))); + ctx.request_repaint(); + } else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { + // macOS ⌘W closes the last window when no document tabs remain (#869). Route through + // the normal viewport-close path (and its quit guard), not the disabled document-only + // `file.close` command. + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.execute(id); + } + } } /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From @@ -789,57 +789,6 @@ fn tab_after(current: Option, count: usize, forward: bool) -> Option = None; - let mut clear = false; - ui.menu_button(label, |ui| { - crate::chrome::scroll_menu(ui, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } - }) - }); - if let Some(p) = open { - app.open_recent(&p); - ui.close(); - } - if clear { - app.execute("file.clear_recent"); - ui.close(); - } - continue; - } - let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); - let enabled = app.command_enabled(spec); - let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); - if resp.clicked() { - app.execute(spec.id); - ui.close(); - } - } - let _ = widgets::menu_item; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..3446bcd26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ mod js_ui; mod macos_association; mod marks_ui; mod measure_ui; +mod menus; mod ocr_ui; mod optimize_ui; mod search_ui; @@ -927,6 +928,10 @@ pub struct PdfCraftApp { /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). deferred_commands: Vec<(&'static str, Option)>, + /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. + pub(crate) native_menu: Option, + /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + pub(crate) menu_input: Vec, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, @@ -1261,6 +1266,8 @@ impl PdfCraftApp { last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), + native_menu: None, + menu_input: Vec::new(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -3291,7 +3298,12 @@ impl PdfCraftApp { { return; } - self.registry_shortcuts(ctx); + // The in-window menu bar holds the keys while one of its menus is open or it has the + // keyboard (Alt, F10); a command shortcut leaves it and runs. + craft_menu::bar::leave_for_shortcut(ctx); + if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + self.registry_shortcuts(ctx); + } // (Esc closes a Combine file shown large first.) if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); @@ -3499,6 +3511,10 @@ impl PdfCraftApp { } // Before egui reads this frame's wheel input. self.scroll_speed.apply(ctx, raw); + // The native menu's key equivalents. + if let Some(native) = self.native_menu.as_mut() { + native.raw_input(raw); + } } } @@ -3670,6 +3686,12 @@ impl eframe::App for PdfCraftApp { self.poll_print(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); + self.native_menu_clicks(ctx); + // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + if !self.menu_input.is_empty() { + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + } self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. @@ -3700,6 +3722,7 @@ impl eframe::App for PdfCraftApp { self.process_picked(); // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); + self.sync_native_menu(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000000..786840b650 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,791 @@ +//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the +//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu. +//! +//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and +//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only +//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard +//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every +//! one of them, once per click, whichever bar it came from. + +use craft_keymap::{Key, Mods}; +use craft_menu::{ + Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target, +}; +use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS}; +use pdfcraft_engine::commands::{self, Shortcut}; + +use crate::canvas::{Fit, PageLayout}; +use crate::theme::ThemePreference; +use crate::{PdfCraftApp, RightPanel}; + +/// The app's name in the menus the layouts write (About, Hide, Quit). +pub(crate) const APP_NAME: &str = "PdfCraft"; + +/// The top bar's width after the menus: the mode tabs, the search box and the buttons. +const ROW_AFTER_MENUS: f32 = 720.0; +/// The narrowest the menu bar gets: one "Menu" title. +const MIN_MENUS_WIDTH: f32 = 80.0; + +/// Menu-only commands. The registry's commands keep their own ids. +pub(crate) const CUT: &str = "edit.cut"; +pub(crate) const PASTE: &str = "edit.paste"; +pub(crate) const DELETE: &str = "edit.delete"; +pub(crate) const SELECT_ALL: &str = "edit.select_all"; +pub(crate) const NO_RECENT: &str = "file.no_recent"; +/// An open document in the Window menu, `{"index": n}` in the tab strip. +pub(crate) const DOCUMENT: &str = "window.document"; +/// A side panel, `{"panel": name}` ([`PANELS`]). +pub(crate) const PANEL: &str = "view.panel"; +const ACTUAL_SIZE: &str = "view.zoom.actual_size"; +const PAGE_LEVEL: &str = "view.zoom.page_level"; +const FIT_WIDTH: &str = "view.zoom.fit_width"; +const FIT_HEIGHT: &str = "view.zoom.fit_height"; +const ZOOM_IN: &str = "view.zoom.in"; +const ZOOM_OUT: &str = "view.zoom.out"; +const ROTATE_CW: &str = "view.rotate.cw"; +const ROTATE_CCW: &str = "view.rotate.ccw"; + +/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view). +const EXTRA: &[(&str, &str, Option)] = &[ + (CUT, "Cut", Some(Shortcut::cmd("X"))), + (PASTE, "Paste", Some(Shortcut::cmd("V"))), + (DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })), + (SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)), + (NO_RECENT, "No recent files", None), + (ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)), + (PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)), + (FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)), + (FIT_HEIGHT, "Fit to height", None), + (ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)), + (ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)), + (ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)), + (ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)), +]; + +/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label. +const PANELS: &[(RightPanel, &str, &str)] = &[ + (RightPanel::Comments, "comments", "Comments"), + (RightPanel::Bookmarks, "bookmarks", "Bookmarks"), + (RightPanel::Pages, "pages", "Pages"), + (RightPanel::Fields, "fields", "Fields"), + (RightPanel::Layers, "layers", "Layers"), + (RightPanel::Attachments, "attachments", "Attachments"), + (RightPanel::Signatures, "signatures", "Signatures"), + (RightPanel::Accessibility, "accessibility", "Accessibility Checker"), + (RightPanel::Tags, "tags", "Tags"), + (RightPanel::Search, "search", "Search"), + (RightPanel::Compare, "compare", "Compare"), +]; + +/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose +/// labels have no ASCII letter. +const TITLES: &[&str] = &[ + "File", + "Edit", + "View", + "Pages", + "Sign", + "Window", + "Help", + "Open Recent", + "Create", + "Export", + "Protect", + "Zoom", + "Rotate view", + "Page navigation", + "Page display", + "Side panels", + "Display theme", + "Tools", + "Fill & Sign", +]; + +/// Separator. +const SEP: &str = "---"; + +const FILE: &[&str] = &[ + "create.blank", + "file.open", + "file.open_recent", + "file.pin_folder", + SEP, + "page.combine", + SEP, + "file.save", + "file.save_as", + "file.revert", + SEP, + "file.close", + "file.close_all", + SEP, + "optimize.reduce", + "optimize.advanced", + SEP, + "file.properties", + "file.word_count", + SEP, + "print.dialog", +]; +const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"]; +const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"]; +const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"]; +const EDIT: &[&str] = &[ + "edit.undo", + "edit.redo", + SEP, + CUT, + "edit.copy", + PASTE, + DELETE, + SEP, + SELECT_ALL, + SEP, + "edit.find", + "edit.advanced_search", + SEP, + "edit.column_select", + "edit.snapshot", + SEP, + "edit.attach", + "form.clear", + SEP, + "app.preferences", +]; +const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"]; +const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"]; +const DISPLAY: &[&str] = &[ + "view.layout.single", + "view.layout.two_up", + SEP, + "view.continuous", + "view.page_gaps", + "view.layout.cover", + SEP, + "view.fit_width_scrolling", + "view.fit_one_page", +]; +const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; +const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; +const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const PAGES: &[&str] = &[ + "page.organize", + SEP, + "page.insert_blank", + "page.insert", + "page.replace", + "page.extract", + "page.split", + "page.duplicate", + "page.delete", + SEP, + "page.rotate", + "page.rotate_ccw", + "page.rotate_dialog", + "page.reverse", + SEP, + "page.crop", + "page.boxes", + "page.number", + SEP, + "bookmark.add", + "bookmark.from_structure", +]; +/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar. +const FILL: &[(&str, &str)] = &[ + ("sign.fill.text", "Add text"), + ("sign.fill.check", "Checkmark"), + ("sign.fill.cross", "Cross"), + ("sign.fill.dot", "Dot"), + ("sign.fill.line", "Line"), + ("sign.fill.date", "Date"), + ("sign.fill.signature", "Signature"), + ("sign.fill.initials", "Initials"), +]; +const SIGN: &[&str] = &[ + SEP, + "sign.fill.signature.change", + "sign.fill.initials.change", + "sign.fill.signature.remove", + "sign.fill.initials.remove", + SEP, + "sign.digital", + "sign.certify", + "sign.certify_invisible", + "form.add.signature", + SEP, + "sign.validate", + "sign.panel", +]; +const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"]; +const HELP: &[&str] = + &["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"]; + +/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl` +/// the Control key on macOS. `None` for a key craft-keymap doesn't name. +pub(crate) fn chord(s: Shortcut) -> Option { + let key = Key::parse(s.key)?; + Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key)) +} + +fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option)> { + EXTRA.iter().find(|(e, ..)| *e == id) +} + +/// The English label of an item's command, for the bar's mnemonics. +fn english_label(command: &CommandRef) -> Option { + if let Some(spec) = commands::command(&command.id) { + return Some(spec.label.to_owned()); + } + if let Some((_, label, _)) = extra(&command.id) { + return Some((*label).to_owned()); + } + FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned()) +} + +fn english(labeled: Labeled<'_>) -> Option { + match labeled { + Labeled::Command(command) => english_label(command), + Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()), + } +} + +fn translate(english: &'static str) -> String { + tl!(english).to_owned() +} + +/// The layouts' own labels (About, Quit, Window…) through the app's translations. +pub(crate) fn layout_options() -> LayoutOptions<'static> { + LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) } +} + +impl PdfCraftApp { + /// The app's menus for this frame, before the platform layout: every item's label, shortcut, + /// enabled and checked state as the app is now. A modal dialog doesn't grey anything here; + /// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]). + pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar { + let mut used: Vec<&str> = Vec::new(); + let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec { + ids.iter() + .map(|&id| { + if id == SEP { + return Node::Separator; + } + used.push(id); + self.menu_item(id, None) + }) + .collect() + }; + let file = { + let mut nodes = Vec::new(); + for node in list(FILE, &mut used) { + // Open Recent is the live recent list; Create, Export and Protect follow Combine, + // Optimize and Close. + let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent"); + if recent { + nodes.push(self.recent_menu()); + continue; + } + let after = match &node { + Node::Item(it) => it.command.id.clone(), + _ => String::new(), + }; + nodes.push(node); + match after.as_str() { + "page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))), + "optimize.advanced" => { + nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used))); + nodes.push(Node::Separator); + nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used))); + } + _ => {} + } + } + nodes + }; + // Edit ▸ Pages follows Select All. + let mut edit = Vec::new(); + for node in list(EDIT, &mut used) { + let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL); + edit.push(node); + if select_all { + edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used))); + } + } + let mut sign = vec![Node::submenu( + tl!("Fill & Sign"), + FILL.iter() + .map(|&(id, label)| { + used.push(id); + self.menu_item(id, Some(tl!(label))) + }) + .collect(), + )]; + sign.extend(list(SIGN, &mut used)); + let window = list(WINDOW, &mut used); + let help = list(HELP, &mut used); + let mut view = vec![ + Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)), + Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)), + Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)), + Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)), + Node::Separator, + ]; + view.extend(list(PANES, &mut used)); + view.push(Node::submenu( + tl!("Side panels"), + PANELS + .iter() + .map(|(_, name, label)| { + Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into() + }) + .collect(), + )); + view.extend(list(PANE_COMMANDS, &mut used)); + view.push(Node::Separator); + view.extend(list(MODES, &mut used)); + view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used))); + view.push(Node::Separator); + view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used))); + view.extend(list(&["view.palette"], &mut used)); + let mut bar = MenuBar { + menus: vec![ + Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File), + Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit), + Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View), + Menu::new(tl!("Sign"), craft_menu::tidy(sign)), + Menu::new(tl!("Window"), window).role(MenuRole::Window), + Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help), + ], + documents: self.menu_documents(), + }; + bar.tag_item(&CommandRef::new("help.about"), ItemRole::About); + bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings); + craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command)); + bar + } + + /// One item: a registry command with its live label (Undo names the edit) and shortcut, or a + /// menu-only one. Radio and check items get their kind here, their state from + /// [`PdfCraftApp::menu_state`]. + fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node { + let (english, shortcut) = match commands::command(id) { + Some(spec) => { + let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc)); + (crate::i18n::menu_label(id, ¤t), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY))) + } + None => match extra(id) { + Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut), + None => (id.to_owned(), None), + }, + }; + let mut item = Item::new(id, label.map_or(english, str::to_owned)); + if let Some(c) = shortcut.and_then(chord) { + item = item.shortcut(c); + } + if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) { + item = item.radio(false); + } + item.into() + } + + /// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files. + fn recent_menu(&self) -> Node { + let mut items: Vec = self + .recent + .iter() + .enumerate() + .map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into()) + .collect(); + if items.is_empty() { + items.push(self.menu_item(NO_RECENT, None)); + } else { + items.push(Node::Separator); + items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files")))); + } + let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label); + Node::submenu(crate::i18n::menu_label("file.open_recent", label), items) + } + + /// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu + /// lists, sections apart. + fn tools_menu(&self, used: &[&str]) -> Vec { + TOOL_GROUPS + .iter() + .filter(|g| g.availability == Availability::Ready) + .map(|group| { + let mut nodes = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + for section in group.sections { + nodes.push(Node::Separator); + for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) { + let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions }; + for &id in actions { + if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) { + continue; + } + seen.push(id); + let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None }; + nodes.push(self.menu_item(id, label)); + } + } + } + Node::submenu(tl!(group.label), nodes) + }) + .collect() + } + + /// The open documents, in tab order, for the Window menu. + fn menu_documents(&self) -> Vec { + (0..self.views.len()) + .filter_map(|index| { + let name = crate::canvas::tab_label(self, index)?; + Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index))) + }) + .collect() + } + + /// What may run from the menus now: a dialog, a prompt or the command palette holds the + /// window. Edit's text items stay with a focused text field (craft-menu's gate). + pub fn menu_gate(&self, ctx: &egui::Context) -> Gate { + Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() } + } + + /// Whether a menu item's command can run now, and its checked or selected state. + pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState { + let view = self.active.and_then(|i| self.views.get(i)); + let organize = view.is_some_and(|v| v.organize); + let has_doc = view.is_some(); + let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s)); + let index = param_index(command); + let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL); + let enabled = text + || match command.id.as_str() { + NO_RECENT => false, + "file.open_recent" => index.is_none_or(|i| i < self.recent.len()), + "file.clear_recent" => !self.recent.is_empty(), + DOCUMENT => index.is_some_and(|i| i < self.views.len()), + // macOS ⌘W closes the window when no document is open (#869). + "file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true, + "edit.copy" if organize => enabled_id("page.copy"), + "edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => { + self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying()) + } + CUT => organize && enabled_id("page.cut"), + PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"), + SELECT_ALL | DELETE => has_doc, + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc, + PANEL => panel(command).is_some(), + id => enabled_id(id), + }; + let checked = match command.id.as_str() { + "view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)), + "view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)), + "view.continuous" => Some(view.is_some_and(|v| v.continuous())), + "view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)), + "view.layout.cover" => Some(view.is_some_and(|v| v.cover)), + "view.full_screen" => Some(self.full_screen), + "view.read_mode" => Some(self.mode == crate::Mode::Read), + "view.side_panel" => Some(self.right.is_some()), + "view.tools_pane" => Some(self.left_open), + "view.theme.system" => Some(self.theme_preference == ThemePreference::System), + "view.theme.light" => Some(self.theme_preference == ThemePreference::Light), + "view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark), + PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))), + _ => None, + }; + ItemState { enabled, checked } + } + + /// Run a command chosen in either menu bar. Registry commands go through + /// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as + /// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the + /// key handles the click. A command that can't run now (a dialog opened since the menu was + /// drawn) does nothing. + pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) { + // Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's. + if Builtin::of(command).is_some() { + return; + } + if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled { + return; + } + let index = param_index(command); + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }; + match command.id.as_str() { + "file.open_recent" if index.is_some() => { + if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) { + self.open_recent(&path); + } + } + DOCUMENT => { + if let Some(i) = index.filter(|&i| i < self.views.len()) { + self.active = Some(i); + } + } + "edit.copy" => self.menu_input.push(egui::Event::Copy), + CUT => self.menu_input.push(egui::Event::Cut), + PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), + DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), + PANEL => self.choose_right_panel(panel(command)), + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => { + let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + match command.id.as_str() { + ACTUAL_SIZE => v.set_zoom(1.0), + PAGE_LEVEL => v.fit = Fit::Page, + FIT_WIDTH => v.fit = Fit::Width, + FIT_HEIGHT => { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + ZOOM_IN => v.zoom_step(true), + ZOOM_OUT => v.zoom_step(false), + ROTATE_CW => v.rotate_view(true), + _ => v.rotate_view(false), + } + } + id => { + if let Some(spec) = commands::command(id) { + self.dispatch_command(ctx, spec.id); + } + } + } + ctx.request_repaint(); + } + + /// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one + /// couldn't be installed), where the top bar starts. Runs the commands chosen. + pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar; + let gate = self.menu_gate(&ctx); + // The mode tabs, search and buttons follow the bar in the same row: it takes its titles' + // width, less when the row is short, where the menus that don't fit move into its "⋯". + let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH); + let chosen = craft_menu::MenuBarUi::new(&bar) + .gate(gate) + .label(&translate) + .english(&english) + .id(egui::Id::unique("pdfcraft-menu-bar")) + .shrink_to_fit(fit) + .show(ui); + for command in chosen { + self.run_menu_command(&ctx, &command); + } + } + + /// Install the native menu bar (macOS), once, from the eframe app creator. `false` when + /// AppKit refused it: the in-window bar stays. + #[cfg(target_os = "macos")] + pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language)); + let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar; + // The Window menu lists the open documents itself, so AppKit's window list stays off. + self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false)); + self.native_menu.is_some() + } + + /// The native menu's clicks since the last frame, run like in-window ones. + pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) { + let gate = self.menu_gate(ctx); + let Some(native) = self.native_menu.as_mut() else { return }; + native.set_gate(gate); + for command in native.take_clicks(ctx) { + self.run_menu_command(ctx, &command); + } + } + + /// Bring the native menu in step with the app, after this frame's changes. + pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) { + if self.native_menu.is_none() { + return; + } + let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()); + let gate = self.menu_gate(ctx); + if let Some(native) = self.native_menu.as_mut() { + native.set_gate(gate); + for clash in native.sync(layout) { + log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution); + } + } + } +} + +/// A command's `{"index": n}` parameter. +fn param_index(command: &CommandRef) -> Option { + command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok()) +} + +/// The side panel a [`PANEL`] command names. +fn panel(command: &CommandRef) -> Option { + let name = command.params.get("panel").and_then(serde_json::Value::as_str)?; + PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu}; + + use super::*; + + const PDF: &[u8] = include_bytes!("../tests/data/form.pdf"); + + fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + for name in docs { + app.open_bytes(name, None, PDF.to_vec()).unwrap(); + } + (app, egui::Context::default()) + } + + fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar { + craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar + } + + fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item { + bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) + } + + #[test] + fn every_menu_command_is_listed_once_with_its_shortcut() { + let (app, ctx) = app(&["a.pdf"]); + let bar = linux(&app, &ctx); + let items = bar.items(); + let mut keys: Vec = items.iter().map(|it| it.command.key()).collect(); + keys.sort(); + let count = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), count, "no command is listed twice"); + for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) { + if spec.id == "file.open_recent" { + continue; // A submenu of the recent files. + } + let it = item(&bar, &CommandRef::new(spec.id)); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY)); + assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id); + } + // Every item runs a registry command or one of the menu's own. + for it in items { + let id = it.command.id.as_str(); + assert!( + commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(), + "{id} runs something" + ); + } + } + + #[test] + fn check_and_radio_items_follow_the_app() { + let (mut app, ctx) = app(&["a.pdf"]); + let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind; + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false)); + let continuous = app.views[0].continuous(); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous)); + app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up")); + app.run_menu_command(&ctx, &CommandRef::new("view.continuous")); + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous)); + let left = app.left_open; + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left)); + app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane")); + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left)); + let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" })); + app.run_menu_command(&ctx, &bookmarks); + assert_eq!(app.right, Some(RightPanel::Bookmarks)); + assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true)); + } + + #[test] + fn a_dialog_greys_document_commands_and_drops_their_clicks() { + let (mut app, ctx) = app(&["a.pdf"]); + let rotate = CommandRef::new("page.rotate"); + assert!(app.menu_gate(&ctx).allows_command(&rotate)); + app.dialog = Some(crate::Dialog::About); + assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog"); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + app.run_menu_command(&ctx, &rotate); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing"); + // Quit still asks the window to close, which asks about unsaved changes. + assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command())); + } + + #[test] + fn recent_files_and_open_documents_are_listed_once_each() { + let (mut app, ctx) = app(&["a.pdf", "b.pdf"]); + app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into(); + let bar = linux(&app, &ctx); + for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() { + assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name); + } + let window = bar.menu(MenuRole::Window).unwrap(); + let documents: Vec<&str> = window + .children + .iter() + .filter_map(|n| match n { + Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()), + _ => None, + }) + .collect(); + assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order"); + app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 }))); + assert_eq!(app.active, Some(0)); + // The macOS layout lists them once too. + let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar; + let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count(); + assert_eq!(listed, 2); + app.recent.clear(); + let bar = linux(&app, &ctx); + assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder"); + assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none()); + } + + /// A native backend that reports what the test queues, through the driver's dispatch as the + /// macOS backend does. + #[derive(Clone, Default)] + struct FakeMenu(Rc, Vec)>>); + + impl FakeMenu { + fn click(&self, command: &CommandRef) { + let mut state = self.0.borrow_mut(); + let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None)); + state.1.extend(event); + } + } + + impl Backend for FakeMenu { + fn sync(&mut self, _bar: &MenuBar) {} + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().1) + } + fn attach(&mut self, dispatch: Dispatch) { + self.0.borrow_mut().0 = Some(dispatch); + } + } + + #[test] + fn a_native_menu_click_runs_its_command_once() { + let (mut app, ctx) = app(&["a.pdf"]); + let fake = FakeMenu::default(); + app.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + fake.click(&CommandRef::new("page.rotate")); + let mut raw = egui::RawInput::default(); + app.prepare_raw_input(&ctx, &mut raw); + app.native_menu_clicks(&ctx); + app.native_menu_clicks(&ctx); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01cab03314..23513cc973 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -213,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response if elided { resp.on_hover_text(placeholder) } else { resp } } -pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { - ui.add(egui::Button::new(label).shortcut_text(shortcut)) -} - /// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px /// box is a circle, the shape of a radio button, and View ▸ Page display has both. const CHECKBOX_RADIUS: u8 = 3; diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 15c23c6296..1e3c36216f 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,15 +293,14 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } -/// Check that choosing Delete pages from the Pages menu reduces the document page count. +/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Pages").hover(); + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Pages").hover(); h.run_steps(3); - h.get_by_label_contains("Delete pages").click(); + support::menu_row(&h, "Delete pages").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); @@ -327,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { } }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); - h.get_by_label("Open Recent").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; - h.get_by_label_contains("recent.pdf").click(); + support::menu_row(&h, "recent.pdf").click(); support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none()); let app = h.state(); let active = app.active.unwrap(); @@ -348,10 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "Open Recent is disabled while no file has been opened" @@ -373,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { app }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); - h.get_by_label("Open Recent").hover(); - h.run_steps(3); - h.get_by_label("Clear Recent Files").click(); + support::menu_row(&h, "Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -458,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings( assert!(!doc.dirty); } -/// Menu ▸ View open, with egui told the app runs on `os`. +/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`. fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { let mut h = harness(); h.ctx.set_os(os); - h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View").hover(); + support::open_menu(&mut h, "View"); + support::menu_row(&h, "Zoom").hover(); h.run_steps(3); h } @@ -473,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { fn view_menu_and_tooltips_write_shortcuts_for_the_platform() { // On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the // registered items under them showed Ctrl+K; tooltips showed ⌘ too. + // The menu bar writes its shortcuts for the build's platform (craft-menu tests that). let h = view_menu(OperatingSystem::Nix); - h.get_by_label("Actual size Ctrl+1"); - h.get_by_label("Zoom to page level Ctrl+0"); - h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−"); - h.get_by_label("Previous view Ctrl+["); - h.get_by_label("Find tools and commands… Ctrl+K"); h.get_by_label("Zoom in (Ctrl++)"); h.get_by_label("Print (Ctrl+P)"); assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS"); assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once"); - h.get_by_label("Fit visible Ctrl+3"); let h = view_menu(OperatingSystem::Mac); - h.get_by_label("Actual size ⌘1"); - h.get_by_label("Fit visible ⌘3"); h.get_by_label("Rotate view clockwise (⇧⌘+)"); assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS"); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index fe96bb5951..e2b09931ea 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -515,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands); let lang = i18n::Lang::from_code(code).unwrap(); - h.get_by_label(i18n::tr(lang, "Menu")).click(); + // A title without an ASCII letter shows its mnemonic after it ("ファイル(F)"). + let file = i18n::tr(lang, "File"); + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click(); h.run_steps(2); - h.get_by_label(i18n::tr(lang, "File")).hover(); - h.run_steps(3); - h.get_by_label_contains(i18n::tr(lang, "Open…")); - ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + // The mnemonic goes before an ellipsis: "開く(O)…". + let open = i18n::tr(lang, "Open…").trim_end_matches('…'); + let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open)) + }; + assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}"); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); h.run_steps(2); } @@ -1013,18 +1017,17 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); + // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic + // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + let menu_row = |text: &'static str| { + move |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) + } + }; + h.get_by(menu_row("編集")).click(); h.run_steps(2); - // The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick - // the popup entry by its id, not by its visible text. - let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" })); - // The tool button reports its toggle state; the popup's menu button does not. - let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry"); - ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] })); + h.get_by(menu_row("環境設定")).click(); h.run_steps(2); - let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); - let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); - ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current })); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0895a8b5be..e5e104d674 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1371,13 +1371,8 @@ fn edit_menu_names_the_step_to_undo() { let mut h = harness(2, |app| { app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); }); - h.get_by_label("Menu").click(); - h.run_steps(2); - // The popup's Edit entry and the left panel's Edit tool carry the same visible label. The - // tool reports a toggle state; the menu entry does not. - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow - h.run_steps(3); - h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Undo Rotate page").click(); h.run_steps(3); let app = h.state(); let doc = app.session.get(app.views[0].id).unwrap(); @@ -3706,7 +3701,7 @@ fn editing_existing_text_in_place() { /// paragraph editor and retypes it as "Chapter One", leaving the editor open. fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - h.get_by_label("Edit").click(); + support::outside_menus(h, "Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); open_the_first_line(h); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..38ffdffd39 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -1,5 +1,6 @@ //! Header & footer, watermark and background dialogs in the real shell (egui_kittest). +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::MarkKind; @@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() { let mut h = harness(); h.state_mut().execute("edit.background"); h.run_steps(2); - h.get_by_label("File").click(); + support::outside_menus(&h, "File").click(); h.run_steps(2); h.get_by_label("No file chosen"); assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file"); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 806fa7153e..8d565ab696 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { }; open(&mut h); h.get_by_label("Run a JavaScript, then 1 more action"); - // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). - assert_eq!(h.get_all_by_label("Edit").count(), 3); - h.get_all_by_label("Edit").nth(1).unwrap().click(); + // The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 4); + h.get_all_by_label("Edit").nth(2).unwrap().click(); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); // While one script is open, no other Edit can overwrite it. - assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled())); h.get_by_label("Discard").click(); h.run_steps(2); - assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled())); // Replace the Mouse Up script with a link: the chained link stays after it. { let d = h.state_mut().field_props.as_mut().unwrap(); diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..62d97d4e58 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -1,5 +1,6 @@ //! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate. +use crate::support; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; @@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { field(&h, "Digital ID password").click(); h.run_steps(1); field(&h, "Digital ID password").type_text("nope"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); field(&h, "Reason").click(); h.run_steps(1); field(&h, "Reason").type_text("I approve"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); @@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() { h.get_by_label("Certify as \"Test Signer EC\""); h.get_by_label("Permitted actions after certifying"); h.state_mut().sign_draft.as_mut().unwrap().password = "test".into(); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); @@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.get_by_label("Fill & Sign signature").click(); h.run_steps(2); assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone()); h.run_steps(2); h.get_by_label("Choose image…"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() { h.get_by_label("Sign as \"Store signer\""); assert_eq!(h.query_all_by_label("Digital ID password").count(), 0); h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it."); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store")); @@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() { let d = h.state_mut().sign_draft.as_mut().unwrap(); (d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into()); h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed)); assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec())); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 174ec5cc0b..f419bb3296 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -51,6 +51,27 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl ); } +/// Open the in-window menu bar's `title` menu (File, Edit, View…). +pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + use egui_kittest::kittest::Queryable; + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click(); + h.run_steps(2); +} + +/// The menu row labelled `label` in an open menu. +pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + +/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it. +pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + /// Whether the test `name` must skip because the interface fonts lack a glyph for a character it /// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses /// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 7f7358bfeb..e480f1c598 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -192,17 +192,14 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { /// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { - use egui_kittest::kittest::{NodeT, Queryable}; + use egui_kittest::kittest::Queryable; let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); - h.run_steps(3); - h.get_by_label_contains("Copy").click(); + crate::support::open_menu(&mut h, "Edit"); + crate::support::menu_row(&h, "Copy").click(); let mut did_copy = false; for _ in 0..4 { h.step(); @@ -229,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() { #[test] fn escape_closes_an_open_menu_before_it_clears_the_selection() { - // #3535: one Escape closed the main menu and dropped the selection with it. - use egui_kittest::kittest::Queryable; + // #3535: one Escape closed the menu and dropped the selection with it. let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.run_steps(2); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); - h.get_by_label("Menu").click(); - h.run_steps(2); + crate::support::open_menu(&mut h, "View"); assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open"); h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index e5e930a971..a02c5c05ca 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { } fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(3); + crate::support::open_menu(h, "View"); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme").hover(); + crate::support::menu_row(h, "Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs index 27bd2f3c9e..c7a2ad739f 100644 --- a/crates/ui-egui/tests/it/trust_signer.rs +++ b/crates/ui-egui/tests/it/trust_signer.rs @@ -3,6 +3,7 @@ //! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the //! self-signed root) differs from its `certificate` (the signer); the click must trust the signer. +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::SignatureStatus; @@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); // The signature validates as Unknown and carries a two-certificate chain whose top (the root) diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12723274d5..cd5ce8dbb1 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -279,7 +279,7 @@ fn expanding_all_tools_from_read_mode_restores_the_right_panel() { fn expanding_all_tools_from_edit_puts_edit_text_down() { use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().quick_tool, QuickTool::EditText); h.state_mut().set_option("tools", "full").unwrap(); @@ -1640,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { use pdfcraft_ui_egui::{Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().quick_tool, QuickTool::Select); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().mode, Mode::Edit); assert_eq!(h.state().quick_tool, QuickTool::EditText); @@ -1649,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { assert_eq!(h.state().mode, Mode::AllTools); assert_eq!(h.state().quick_tool, QuickTool::Select); // Another tool picked inside Edit is not taken away by leaving it. - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.state_mut().quick_tool = QuickTool::Hand; h.get_by_label("Convert").click(); @@ -1663,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { fn organize_pages_from_the_edit_panel_opens_its_panel() { use pdfcraft_ui_egui::LeftPanel; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().left, LeftPanel::Tool("edit")); assert!( @@ -1681,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() { #[test] fn the_edit_panel_keeps_the_add_update_remove_menu() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.get_by_label("Watermark").click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 28eb6fd768..a83b4cb756 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1033,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() { use egui_kittest::kittest::Queryable; let mut h = harness(&[]); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); + crate::support::open_menu(&mut h, "View"); + crate::support::menu_row(&h, "Page display").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -2011,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() { app }); h.run_steps(8); - h.get_by_label("Menu").click(); + crate::support::open_menu(&mut h, "View"); h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(4); - let before = h.get_by_label("Side panels").rect().bottom(); - h.get_by_label("Fit to height ").hover(); + crate::support::menu_row(&h, "Rotate view").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels").rect().bottom(); - assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels").hover(); + let after = crate::support::menu_row(&h, "Side panels").rect().bottom(); + assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); + crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4);