From 94bdfea62b7a050f655cd487ac718f3e602ae8ae Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:49:20 +0800 Subject: [PATCH 01/29] M7: a click away from a picture being cropped applies the crop (edit.image-crop) (#6428) --- crates/ui-egui/src/canvas/page_input.rs | 7 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 39 ++++- crates/ui-egui/tests/it/editing.rs | 162 ++++++++++++++++++ 3 files changed, 199 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 2ca3337a3f..e253fd6b59 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -103,10 +103,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it - // (`object_ui::modified_hit`): the page's objects are read for it alone. + // (`object_ui::modified_hit`): the page's objects are read for it alone. Crop mode holds + // it on its page as any other press, so a modified click away applies the crop rather + // than taking the picture into a group (#6422). let adds = tool == QuickTool::EditText && can_modify && xf.rect.contains(o) + && !hits.cropping(view) && crate::object_ui::adds(ptr.modifiers) && !crate::object_ui::menu_open(ui, resp); let objects = adds.then(|| view.objects.inventory(doc, i)); @@ -532,7 +535,7 @@ impl PageHits<'_> { images, clip, } = *self; - // A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`). + // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect(); } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 61b8fa866f..ba3601d0da 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -441,12 +441,25 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let reset_clicked = gate && reset .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)))); + // A click away from the picture (its shape, uncropped, not its bounds) and its crop box and + // handles applies the crop and lets the picture go, as Done and then that click would + // (#6422). It is pressed away too ([`CropPart::Page`]), so a press on the picture let go just + // past it is not one; a control's click is the control's. + let pressed_away = matches!(press, PagePress::Mine(Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. })); + let away_clicked = gate + && resp.clicked() + && pressed_away + && !(done_clicked || cancel_clicked || reset_clicked) + && hover_pos.is_some_and(|p| matches!(crop_part(xf, info, page, Some(image), mode, p), None | Some(CropPart::Page))); if reset_clicked { mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep }; mode.drag = None; - } else if done_clicked { + } else if done_clicked || away_clicked { apply_crop_mode(view, image); + if away_clicked { + view.image_selection = None; + } } else if cancel_clicked && let Some(s) = view.image_selection.as_mut() { s.crop = None; } @@ -580,28 +593,40 @@ pub(crate) fn handle_hit( pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { let s = view.image_selection.as_ref().filter(|s| s.page == page)?; let mode = s.crop.as_ref()?; - let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); + let part = crop_part(xf, info, page, images.get(s.index), mode, p)?; + let icon = match part { + CropPart::Page if mode.controls.iter().any(|r| r.contains(p)) => egui::CursorIcon::PointingHand, + CropPart::Grip(g) => GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon()), + CropPart::Keep => egui::CursorIcon::Grab, + CropPart::Page | CropPart::Cut => egui::CursorIcon::Default, + }; + Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)) +} + +/// What of crop mode `mode` on the picture `image` lies under `p` ([`crop_hit`]): a control (as +/// [`CropPart::Page`]), a grip, the box kept, the picture outside it, or the rest of the page; +/// `None` off the page, its grips and its controls. +fn crop_part(xf: &PageXform, info: &DocInfo, page: usize, image: Option<&pdfcraft_engine::PageImage>, mode: &CropMode, p: Pos2) -> Option { if mode.controls.iter().any(|r| r.contains(p)) { - return crop(CropPart::Page, egui::CursorIcon::PointingHand); + return Some(CropPart::Page); } - let image = images.get(s.index); let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k); // The grips reach past the page's edge, where those of a picture filling its page lie half. if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) { - return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + return Some(CropPart::Grip(g)); } if !xf.rect.contains(p) { return None; } if kept.is_some_and(|k| k.contains(p)) { - return crop(CropPart::Keep, egui::CursorIcon::Grab); + return Some(CropPart::Keep); } let on_picture = image.is_some_and(|image| { let shown = ImageObb::from_image(image, xf, info, page); let whole = ImageObb::from_matrix(image.matrix, xf, info, page); quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) }); - crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default) + Some(if on_picture { CropPart::Cut } else { CropPart::Page }) } /// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 500d504403..c5d197f64c 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -9155,6 +9155,129 @@ fn test_rotated_image_crop_secondary_click_in_outer_bounding_box_clears_selectio assert!(h.query_by_label("Rotate view clockwise").is_some()); } +/// A turned picture is its shape, not its bounds: a click in a corner of its bounds, off the +/// picture and its crop box, is a click away that applies the crop (#6422 review). +#[test] +fn a_click_beside_a_turned_picture_in_its_bounds_applies_its_crop() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 80, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let full = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0); + let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0); + let edit = |change| pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change }; + h.state_mut().apply_edit(edit(pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]))); + h.run_steps(4); + h.state_mut().apply_edit(edit(pdfcraft_engine::ImageEdit::RotateBy(45.0))); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let c = h.state().views[0].page_screen_rect(0).expect("on screen").center(); + click_at(&mut h, c); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new([mx - 5.0, my - 5.0, mx + 5.0, my + 5.0])); + h.run_steps(2); + + let img = h.state().session.get(id).unwrap().page_images(0)[0].clone(); + let xf = h.state().views[0].page_xform(0).unwrap(); + let b = pdfcraft_ui_egui::canvas::image_screen_rect(&img, &xf, &h.state().session.get(id).unwrap().info, 0); + // The bounds' top-left corner lies outside the 45° diamond. + click_at(&mut h, b.left_top() + egui::vec2(2.0, 2.0)); + assert!(h.state().views[0].image_selection.is_none(), "a click off the turned picture lets it go"); + assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].visible_rect, img.visible_rect, "the crop is applied"); +} + +/// A picture alone in the middle of a page, empty page around it (#6422). +fn picture_with_room_around() -> Vec { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let image = doc.add(Object::Stream(Stream::flate(image, &[60, 110, 200]))); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 100 0 0 80 50 150 cm /Im0 Do Q"))); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + write_incremental(&doc, &Default::default()).unwrap() +} + +/// In crop mode a click on the empty page applies the crop and lets the picture go; a click on the +/// part being cropped away keeps crop mode (#6422). +#[test] +fn a_click_away_from_the_picture_applies_its_crop() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("room.pdf", None, picture_with_room_around()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + let doc_id = h.state().views[0].id; + let full = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect; + + click_at(&mut h, at(100.0, 190.0)); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.index == 0), "the picture is selected"); + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(full)); + h.run_steps(2); + let keep = [full[0], full[1], full[0] + 60.0, full[3]]; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = keep; + h.run_steps(2); + + // On the dimmed part of the picture: still cropping, nothing applied. + click_at(&mut h, at(135.0, 190.0)); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a click on the picture keeps crop mode"); + assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet"); + + // On a handle, where it reaches past the picture's frame: still cropping. + let handle = at(50.0, 190.0) - egui::vec2(6.0, 0.0); + click_at(&mut h, handle); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a click on a handle keeps crop mode"); + assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet"); + + // A click pressed on the picture and let go just past its frame: still cropping. + let edge = at(150.0, 190.0); + h.hover_at(edge - egui::vec2(2.0, 0.0)); + h.run_steps(1); + h.drag_at(edge - egui::vec2(2.0, 0.0)); + h.run_steps(1); + h.hover_at(edge + egui::vec2(2.0, 0.0)); + h.run_steps(1); + h.drop_at(edge + egui::vec2(2.0, 0.0)); + h.run_steps(4); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "a press on the picture keeps crop mode"); + assert_eq!(h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect, Some(full), "nothing is applied yet"); + + // On the empty page: applied, and the picture is let go. + click_at(&mut h, at(100.0, 60.0)); + assert!(h.state().views[0].image_selection.is_none(), "a click away lets the picture go"); + let visible = h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect.unwrap(); + assert!(keep.iter().zip(visible).all(|(a, b)| (a - b).abs() < 0.5), "the crop is applied: {visible:?}"); +} + #[test] fn crop_action_pills_interactive_and_apply_cancel_reset() { let mut png = Vec::new(); @@ -11052,3 +11175,42 @@ fn affine_review_cropped_rotation_preview_matches_saved_corners() { ); } } + +#[test] +fn a_modified_click_away_from_a_cropped_picture_applies_its_crop() { + // Shift-, Ctrl- or Command-clicking away is still a click away: it applies the crop and lets + // the picture go, rather than taking the picture into a group and dropping its crop (#6422 + // review). + for mods in [egui::Modifiers::SHIFT, egui::Modifiers::COMMAND] { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("room.pdf", None, picture_with_room_around()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + let doc_id = h.state().views[0].id; + let full = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect; + click_at(&mut h, at(100.0, 190.0)); + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(full)); + h.run_steps(2); + let keep = [full[0], full[1], full[0] + 60.0, full[3]]; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = keep; + h.run_steps(2); + let p = at(100.0, 60.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }); + h.run_steps(4); + assert!(h.state().views[0].image_selection.is_none(), "{mods:?}: the picture is let go"); + let visible = h.state().session.get(doc_id).unwrap().page_images(0)[0].visible_rect.unwrap(); + assert!(keep.iter().zip(visible).all(|(a, b)| (a - b).abs() < 0.5), "{mods:?}: the crop is applied: {visible:?}"); + } +} From de7ed00a1a7a21adc7defbe9b2a2458bebb773b3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:49:35 +0800 Subject: [PATCH 02/29] M3: End shows the end of the document, the last page down to its bottom (view.page-navigation) (#6435) --- crates/ui-egui/src/canvas/navigation.rs | 19 +++++++++-- crates/ui-egui/src/commands.rs | 3 ++ crates/ui-egui/src/dialogs.rs | 8 +++-- crates/ui-egui/tests/it/viewer.rs | 42 +++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 1a2a0343a6..49dc3336c6 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -108,6 +108,16 @@ impl DocView { self.current = page; } + /// The end of the document (End): the last page, scrolled to its bottom as far as the view + /// goes, as browsers and word processors do (#6434). [`go_to_page`](Self::go_to_page) shows a + /// page's top. + pub fn go_to_end(&mut self) { + self.go_to_page(usize::MAX); + // A fraction past a page's top scrolls as far as the content allows (`goto`, clamped when + // applied), so the whole page down to its bottom edge. + self.goto = Some((self.current, 1.0)); + } + /// Reveal a user-space rectangle without changing zoom, centred where the scroll bounds allow. pub(crate) fn reveal_rect(&mut self, page: usize, rect: [f32; 4], info: &DocInfo) { self.go_to_page(page); @@ -407,11 +417,14 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } - if key(Key::Home) { + // Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl + // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434). + let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); + if key(Key::Home) || pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)) { view.go_to_page(0); } - if key(Key::End) { - view.go_to_page(usize::MAX); + if key(Key::End) || pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)) { + view.go_to_end(); } // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the // arrows and Page Up / Page Down already turn pages where nothing scrolls, below). diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 7c0df87782..73b097c706 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -30,6 +30,9 @@ pub(crate) const PASTE: Shortcut = Shortcut::cmd("V"); pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A"); pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←"); pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→"); +/// Go to first / last page beside Home and End (`canvas::navigation`). +pub(crate) const FIRST_PAGE: Shortcut = Shortcut::cmd_shift("↑"); +pub(crate) const LAST_PAGE: Shortcut = Shortcut::cmd_shift("↓"); /// Whether shortcuts are written the macOS way (`⇧⌘S`) rather than `Ctrl+Shift+S`. egui knows the /// platform from the build target, and on the web from the browser's user agent. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7ce76c420e..c4a49acd67 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1498,7 +1498,8 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); ui.add_space(8.0); use crate::commands::{ - ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT, + ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIRST_PAGE, FIT_WIDTH, LAST_PAGE, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, + SELECT_ALL, ZOOM_IN, ZOOM_OUT, }; // Registered commands first (always in sync with the real bindings), then the // keys the document view handles itself. @@ -1528,7 +1529,10 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { (key(FIT_WIDTH), tl!("Fit to width").to_string()), (pair(ZOOM_IN, ZOOM_OUT), scroll), (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), - (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), + ( + format!("{} / {}, {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End"), pair(FIRST_PAGE, LAST_PAGE)), + tl!("First / last page").to_string(), + ), (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), (alt_arrows, format!("{} / {}", tl!("Previous view"), tl!("Next view"))), (named("V"), tl!("Select (V)").to_string()), diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 9a06a0bc12..341e718c16 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -532,6 +532,48 @@ fn restart_page_labels(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(3); } +/// End shows the end of the document, the last page's bottom edge, when the page is taller than +/// the window; Home goes back to the first page's top (#6434). +#[test] +fn end_shows_the_bottom_of_the_last_page() { + use egui::Key; + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + let vp = h.state().views[0].viewport_rect(); + assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.height() > vp.height()), "a page is taller than the window"); + + h.key_press(Key::End); + h.run_steps(4); + let v = &h.state().views[0]; + assert_eq!(v.current, 4, "the last page"); + let last = v.page_screen_rect(4).expect("the last page is on screen"); + assert!(last.bottom() <= vp.bottom() + 1.0 && last.bottom() > vp.bottom() - 40.0, "its bottom edge is at the window's: {last:?} in {vp:?}"); + + h.key_press(Key::Home); + h.run_steps(4); + let v = &h.state().views[0]; + assert_eq!(v.current, 0, "the first page"); + let first = v.page_screen_rect(0).expect("the first page is on screen"); + assert!(first.top() >= vp.top() - 1.0 && first.top() < vp.top() + 40.0, "its top edge is at the window's: {first:?} in {vp:?}"); + + // The keys listed with them for Go to first / last page do the same. + let shift_cmd = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + for (last, first) in [(Key::ArrowDown, Key::ArrowUp), (Key::PageDown, Key::PageUp)] { + h.key_press_modifiers(shift_cmd, last); + h.run_steps(4); + let v = &h.state().views[0]; + let bottom = v.page_screen_rect(4).map(|r| r.bottom()); + assert!(v.current == 4 && bottom.is_some_and(|b| (b - vp.bottom()).abs() < 40.0), "{last:?}: {bottom:?} in {vp:?}"); + h.key_press_modifiers(shift_cmd, first); + h.run_steps(4); + let v = &h.state().views[0]; + let top = v.page_screen_rect(0).map(|r| r.top()); + assert!(v.current == 0 && top.is_some_and(|t| (t - vp.top()).abs() < 40.0), "{first:?}: {top:?} in {vp:?}"); + } +} + #[test] fn page_label_input_keeps_current_page_when_submitted_unchanged() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fbb8d7fd7f..a3b6c0a198 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"] -notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it", "crates/ui-egui/tests/it/viewer.rs::end_shows_the_bottom_of_the_last_page"] +notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel; Home shows the first page's top and End the end of the document, the last page down to its bottom edge; Shift+Ctrl (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down do the same as Home and End (Shift+Ctrl+Page Up / Page Down used to turn one page, like Ctrl+Page Up / Page Down)" [[feature]] id = "view.goto-page-label" From 608ff62dae6cbe9dd47f34bbe3278490804b0731 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:49:56 +0800 Subject: [PATCH 03/29] M6: a widget whose /Parent names a missing object is a field of its own (form.field-list) (#6445) --- crates/forms/src/read.rs | 11 +++++++-- crates/forms/src/tests.rs | 31 ++++++++++++++++++++++++ crates/render/src/inspect/annotations.rs | 6 +++-- crates/render/src/inspect/tests.rs | 20 +++++++++++++++ parity/acrobat-features.toml | 4 +-- 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 34775ee75e..f693280086 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -106,12 +106,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { parts.push(t); } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); + cur = parent_of(doc, d); } parts.reverse(); (!parts.is_empty()).then(|| parts.join(".")) } +/// The field `d` names as its `/Parent`, when that is a dictionary. A reference to an object the +/// file doesn't hold reads as `null`, and a `null` entry is the same as an absent one (ISO +/// 32000-2 §7.3.9): the field is then a root, as if it named no parent. +fn parent_of(doc: &Document, d: &Dict) -> Option { + d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some()) +} + /// JavaScript streams one reading of the form (its fields, or its document scripts) may decode /// in all, however many actions name the same stream. pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20; @@ -413,7 +420,7 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary + "<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field + "[1 2 3]".into(), // 9 + ]; + let mut doc = document(&objs); + assert_combined_matches_legacy(&doc, 2); + let all = fields(&doc); + let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["city", "zip"]); + let city = field(&all, "city"); + assert_eq!( + (city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page), + (FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0)) + ); + set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap(); + let doc = reopen(&doc); + assert_eq!(field(&fields(&doc), "zip").value, ["0150"]); +} + /// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on /// states by position (`/0`, `/1`): the export values select them, as in Acrobat. #[test] diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 5ed635fdcb..351d2f8b64 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -458,7 +458,7 @@ impl<'a> Inspector<'a> { pages } - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an + /// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { let mut top = id; @@ -466,8 +466,10 @@ impl<'a> Inspector<'a> { loop { let o = Object::Reference(top); let d = self.dict(&o)?; + // A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as + // `null`, the same as no parent (ISO 32000-2 §7.3.9). let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), + Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p), _ => None, }; let Some(p) = parent else { diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 012e40f79a..c7b8fd572e 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -786,6 +786,26 @@ fn fields_listed_only_on_the_pages_are_listed() { assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); } +/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary, +/// is listed as a field of its own: that parent reads as `null`, the same +/// as none (ISO 32000-2 §7.3.9). +#[test] +fn a_widget_whose_parent_is_missing_is_listed() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj +4 0 obj << /Fields [] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj +7 0 obj [1 2 3] endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); + assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); +} + /// A `Structure` that counts the objects read through it. struct CountingReads<'a> { doc: &'a Document, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a3b6c0a198..967a424d24 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4590,8 +4590,8 @@ milestone = "M3" status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] -notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too" +evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/render/src/inspect/tests.rs::a_widget_whose_parent_is_missing_is_listed", "crates/forms/src/tests.rs::a_widget_whose_parent_is_missing_is_a_field_of_its_own", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] +notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too, and a widget whose /Parent names a missing object is a field of its own" [[feature]] id = "form.highlight-fields" From e83c2005989b623b7ce09b3cf5d9d8b05d921b6d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:50:11 +0800 Subject: [PATCH 04/29] M6: a field whose /Kids loop back or name a root field keeps its other kids (form.field-list) (#6446) --- crates/forms/src/read.rs | 66 +++++++++++++++---- crates/forms/src/tests.rs | 79 +++++++++++++++++++++++ crates/render/src/inspect/annotations.rs | 82 ++++++++++++++++++++++-- crates/render/src/inspect/core.rs | 16 +++-- crates/render/src/inspect/tests.rs | 61 ++++++++++++++++++ 5 files changed, 280 insertions(+), 24 deletions(-) diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index f693280086..16bdae314d 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -338,13 +338,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); + let mut by_parent: std::collections::HashMap> = std::collections::HashMap::new(); let pages = page_refs(doc); for (i, p) in pages.iter().enumerate() { if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); + if let Some(r) = e.as_ref() + && let std::collections::hash_map::Entry::Vacant(slot) = page_of.entry(r) + { + slot.insert(i); + annot_index.insert(r, k); + if let Some(parent) = widget_parent(doc, r) { + by_parent.entry(parent).or_default().push(r); + } } } } @@ -358,18 +364,24 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); + let listed_roots: Vec = af + .as_ref() + .and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())) + .unwrap_or_default() + .iter() + .filter_map(Object::as_ref) + .collect(); let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), + roots: listed_roots.iter().copied().collect(), + path: Vec::new(), + by_parent, scripts: script_budget(), values: DecodeBudget::new(MAX_VALUE_BYTES, 0), }; - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } + for r in listed_roots { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } let listed = out.len(); adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); @@ -440,10 +452,36 @@ struct Seen { fields: std::collections::HashSet, /// A widget belongs to the first field that lists it, however often it is listed. widgets: std::collections::HashSet, + /// The fields `/Fields` lists: root fields, with no ancestors (ISO 32000-2 §12.7.3, Table 224). + roots: std::collections::HashSet, + /// The fields being walked, outermost first, whose `/Kids` are being read. + path: Vec, + /// The page widgets (no `/T`) that name each field as their `/Parent`, in page-annotation order. + by_parent: std::collections::HashMap>, scripts: DecodeBudget, values: DecodeBudget, } +/// The field a page widget `r` names as its `/Parent`, when `r` is a widget and not a field of its +/// own (no `/T`). +fn widget_parent(doc: &Document, r: ObjRef) -> Option { + let obj = doc.get(r); + let d = obj.as_dict()?; + if d.name(b"Subtype") != Some(b"Widget") || d.contains(b"T") { + return None; + } + d.reference(b"Parent") +} + +/// Whether `k`, listed in the `/Kids` of field `r`, is not a child of `r` after all: `r` itself or +/// a field `r` descends from (a loop), or a root field (one `/Fields` lists) that doesn't name `r` +/// as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), and a +/// child names its parent (§12.7.4.1, Table 226): only the `/Kids` entry says otherwise, so the +/// field is read as the root it is, and `r` keeps its other kids. +fn not_a_child(doc: &Document, seen: &Seen, r: ObjRef, k: ObjRef) -> bool { + k == r || seen.path.contains(&k) || (seen.roots.contains(&k) && doc.get(k).as_dict().and_then(|d| d.reference(b"Parent")) != Some(r)) +} + /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. fn walk( @@ -490,11 +528,14 @@ fn walk( let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + let (field_kids, kids): (Vec, Vec) = kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))); + let field_kids: Vec = field_kids.into_iter().filter(|k| !not_a_child(doc, seen, r, *k)).collect(); if !field_kids.is_empty() { + seen.path.push(r); for k in field_kids { walk(doc, k, &inh, page_of, seen, out, depth + 1); } + seen.path.pop(); return; } // A field of no kind this reads is skipped before it claims widgets or scripts, which a later @@ -510,7 +551,10 @@ fn walk( Some(b"Sig") => FieldKind::Signature, _ => return, }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + // A child names its parent (ISO 32000-2 §12.7.4.1, Table 226): a page widget naming this field + // is one of its widgets even when its `/Kids` leave it out, as MuPDF and PDFium read it. + let widget_refs: Vec = + if d.contains(b"Rect") { vec![r] } else { kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() }; let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3ffc253877..a96a519d8c 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() { } } +/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root +/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3, +/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6 + "<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8 + "<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7 + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9 + "<< /T (group) /Kids [13 0 R] >>".into(), // 10 + "<< /Length 0 >> +stream + +endstream" + .into(), // 11 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12 + "<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13 + "<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15 + ]; + let doc = document(&objs); + let all = fields(&doc); + let listed: Vec<(&str, FieldKind, Vec)> = + all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, vec![r(6)]), + ("left", FieldKind::Radio, vec![r(9)]), + ("right", FieldKind::Radio, vec![r(12)]), + ("group.inner.deep", FieldKind::Text, vec![r(15)]), + ] + ); +} + +/// A page widget whose `/Parent` names a field is one of that field's widgets even when the +/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here +/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots, +/// so not children) and none of their widgets. Each field gets every widget naming it, once. +#[test] +fn widgets_naming_a_field_that_does_not_list_them_join_it() { + let w = |parent: u32, state: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>", + y + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3 + w(5, "A", 700), // 4: not in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5 + "<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6 + w(5, "B", 650), // 7: in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9 + "<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 10 + w(8, "C", 600), // 11: in 8's /Kids + w(8, "D", 550), // 12: not in 8's /Kids + w(9, "E", 500), // 13: not in 9's /Kids + ]; + let doc = document(&objs); + let (all, adopted) = enumerate(&doc); + let listed: Vec<(&str, Vec)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]); + assert_eq!(adopted, 0, "no widget is read as a field of its own"); + let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect(); + assert_eq!(states, ["B", "A"]); +} + /// Some writers leave every field out of `/Fields` and put the widgets only in the page /// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway. /// The pages' widgets are adopted as fields, after the listed tree fields. diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 351d2f8b64..bcf84ee07b 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -370,14 +370,14 @@ impl<'a> Inspector<'a> { o: &'a Object, parent: Option<(&str, Option, Option)>, out: &mut Vec, - seen: &mut HashSet, + walk: &mut FieldWalk, depth: u32, ) { if depth > 32 { return; } if let Object::Reference(id) = o - && !seen.insert(*id) + && !walk.seen.insert(*id) { return; } @@ -396,10 +396,16 @@ impl<'a> Inspector<'a> { Ok(Object::Array(k)) => k.as_slice(), _ => &[], }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); + let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())); + let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect(); if !field_kids.is_empty() { + let id = if let Object::Reference(id) = o { Some(*id) } else { None }; + walk.path.extend(id); for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); + self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1); + } + if id.is_some() { + walk.path.pop(); } return; } @@ -429,14 +435,67 @@ impl<'a> Inspector<'a> { Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), _ => None, }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); + // The widget is either this dict (merged field/widget) or its first kid. With no widget + // among its kids, it is the first page widget naming this field as its `/Parent`: a child + // names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and PDFium read it. + let merged = d.get(b"Rect").is_ok(); + let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) }; + let widget = if merged { + Some(d) + } else { + widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok())) + }; + let page = widget + .and_then(|w| w.get(b"P").ok()) + .and_then(|p| self.page_of(p)) + .or_else(|| self.find_widget_page(o, kids)) + .or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied())); let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); } + /// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself + /// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't + /// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), + /// and a child names its parent (§12.7.4.1, Table 226). + fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool { + let Object::Reference(k) = k else { return false }; + let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") { + Ok(Object::Reference(p)) => Some(*p), + _ => None, + }); + let o = if let Object::Reference(o) = o { Some(*o) } else { None }; + o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o)) + } + + /// The first page widget that names field `o` as its `/Parent` and is not a field of its own + /// (no `/T`), in page-annotation order. + fn widget_naming(&self, o: &Object) -> Option { + let Object::Reference(id) = o else { return None }; + self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied() + } + + /// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order. + fn parent_widgets(&self) -> HashMap { + let mut first = HashMap::new(); + for &pid in &self.page_ids { + let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; + let Ok(annots) = self.resolve(annots).as_array() else { continue }; + for a in annots { + let Object::Reference(id) = a else { continue }; + let Ok(w) = self.doc.get_dictionary(*id) else { continue }; + if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() { + continue; + } + if let Ok(Object::Reference(parent)) = w.get(b"Parent") { + first.entry(*parent).or_insert(*id); + } + } + } + first + } + /// The first page whose `/Annots` lists the field or one of its kids. fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); @@ -486,6 +545,15 @@ impl<'a> Inspector<'a> { } } +/// What one reading of the form's fields keeps: the fields read, the root fields `/Fields` lists, +/// and the fields whose `/Kids` are being read, outermost first. +#[derive(Default)] +pub(super) struct FieldWalk { + pub(super) seen: HashSet, + pub(super) roots: HashSet, + path: Vec, +} + /// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a /// `u32` counts as no flags. fn draw_flags(o: &Object) -> u32 { diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 924bfcc51d..a7a1d5ba7d 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,4 +1,4 @@ -use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; +use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES}; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; use super::{ @@ -144,13 +144,16 @@ pub(super) struct Inspector<'a> { /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × /// annotations): a second on a form of 8,000 fields. pub(super) annotation_pages: OnceCell>, + /// The first page widget naming each field as its `/Parent`, built when a field has no widget + /// among its `/Kids`. + pub(super) parent_widgets: OnceCell>, } impl<'a> Inspector<'a> { pub(super) fn new(doc: &'a dyn Structure) -> Self { let page_ids = doc.page_ids(); let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() }; let mut named = HashMap::new(); if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) @@ -210,12 +213,13 @@ impl<'a> Inspector<'a> { let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; self.annotations(info, &mut text_budget); self.transitions(info); - let mut field_seen = HashSet::new(); + let mut walk = FieldWalk::default(); if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) { + walk.roots = fields.iter().filter_map(|f| if let Object::Reference(id) = f { Some(*id) } else { None }).collect(); for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); + self.field(f, None, &mut info.fields, &mut walk, 0); } } // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); @@ -231,9 +235,9 @@ impl<'a> Inspector<'a> { continue; } if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) + && let Some(root) = self.unlisted_field(*id, &walk.seen) { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); + self.field(&Object::Reference(root), None, &mut info.fields, &mut walk, 0); } } } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index c7b8fd572e..6b4ee43378 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1986,3 +1986,64 @@ fn inspection_keeps_no_private_page_data() { }; assert_eq!(report(with), report(without), "what inspection reports does not change"); } + +/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its +/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field +/// keeps its other kids and is listed with them. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj +4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj +9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj +10 0 obj << /T (group) /Kids [13 0 R] >> endobj +12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj +14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj +15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, Some(0), Some(150.0)), + ("left", FieldKind::Radio, Some(0), Some(120.0)), + ("right", FieldKind::Radio, Some(0), Some(90.0)), + ("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)), + ], + "{:?}", + info.fields + ); +} + +/// A field with no widget among its `/Kids` takes the page widget that names it as its +/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other` +/// list each other (roots, so not children), and only a page widget says it belongs to `other`. +#[test] +fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj +6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj +7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj +9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj +11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields); +} From 38ba2e7495522d0e9d95a521977a0e8156649b37 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:50:25 +0800 Subject: [PATCH 05/29] M5: a font a page lists among its annotations is not a comment (comment.list-view) (#6447) --- crates/render/src/inspect/annotations.rs | 10 ++++++++++ crates/render/src/inspect/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index bcf84ee07b..1609ad7e75 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -118,6 +118,16 @@ impl<'a> Inspector<'a> { let mut comments_full = false; for (page, index, a) in entries { let Some(d) = self.dict(a) else { continue }; + // An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166): + // a font or other dictionary a page lists among its annotations isn't one, and neither is + // one whose `/Type` isn't a name at all. A null or dangling `/Type` counts as absent + // (§7.3.9: a null value is the same as leaving the entry out). + if let Ok(t) = d.get(b"Type").map(|t| self.resolve(t)) + && !matches!(t, Object::Null | Object::Reference(_)) + && t.as_name().ok() != Some(b"Annot".as_slice()) + { + continue; + } let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" && let Ok(fs) = d.get(b"FS") diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 6b4ee43378..a2e559ac44 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -825,6 +825,30 @@ impl Structure for CountingReads<'_> { } } +/// A font (or any dictionary whose `/Type` is present and isn't `/Annot`) a page lists in `/Annots` +/// isn't an annotation (ISO 32000-2 §12.5.2, Table 166) and isn't listed as a comment; the real ones +/// around it keep their place in the array, and an entry without `/Type` is still read as one. A +/// `/Type` that isn't a name (`/Type 42`) isn't `/Annot` either, while `/Type null` counts as absent. +#[test] +fn dictionaries_of_another_type_in_annots_are_not_annotations() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (typed) >> endobj +6 0 obj << /Type /Font /Subtype /TrueType /BaseFont /Arial >> endobj +7 0 obj << /Subtype /Square /Rect [40 40 60 60] /Contents (untyped) >> endobj +8 0 obj << /Type 42 /Subtype /Text /Rect [70 70 90 90] /Contents (number type) >> endobj +9 0 obj << /Type null /Subtype /Circle /Rect [100 100 120 120] /Contents (null type) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let mut listed: Vec<_> = info.annotations.iter().map(|a| (a.subtype.as_str(), a.index, a.contents.as_deref())).collect(); + listed.sort_by_key(|a| a.1); + assert_eq!(listed, [("Text", 1, Some("typed")), ("Square", 3, Some("untyped")), ("Circle", 5, Some("null type"))], "{:?}", info.annotations); +} + /// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad /// with a non-number is left out rather than drawn with a zero, an indirect number is followed, /// and a trailing partial quad is dropped. From 72311bc1015dee23c6e2f0d3198476fdeb26a1e9 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:50:39 +0200 Subject: [PATCH 06/29] Signature validation takes the certification from the catalog (#6453) --- crates/sign/src/pdf/types.rs | 4 +- crates/sign/src/pdf/validate.rs | 53 +++++++++++++++++++----- crates/sign/tests/it/pdf.rs | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/sign/src/pdf/types.rs b/crates/sign/src/pdf/types.rs index 089cc2e9d7..93883e7584 100644 --- a/crates/sign/src/pdf/types.rs +++ b/crates/sign/src/pdf/types.rs @@ -41,7 +41,7 @@ pub mod kind { pub const PAGES: &str = "pages added or removed"; pub const OTHER: &str = "other changes"; /// Not a change but the reason there is nothing to compare: the signed revision itself could not - /// be read, so what came after it cannot be allowed. + /// be read, or does not hold the signature, so what came after it cannot be allowed. pub const UNREADABLE: &str = "the signed version could not be read"; /// Every word above, for an interface to translate. @@ -177,6 +177,8 @@ pub const WORDINGS: &[&str] = &[ "The signature value does not match the signer's certificate: the signature is corrupt.", "The signature could not be checked ({}).", "The signature could not be verified ({}).", + // Whether the signature certifies the document. + "This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.", // What came after the signature. "This document has not been modified since this signature was applied.", "The document has been modified since this signature was applied, but the changes are permitted ({}).", diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 09a4e5264c..52bf79351f 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -38,9 +38,9 @@ pub(super) fn is_doc_timestamp(d: &Dict) -> bool { d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig")) } -/// The DocMDP permission level (`/P`, 1–3) of signature value `v`, or `None` for an approval -/// signature. -fn docmdp(doc: &Document, v: &Dict) -> Option { +/// The DocMDP permission level (`/P`, 1 to 3) that signature value `v` asks for in its `/Reference`, +/// or `None` when it asks for none. +fn docmdp_reference(doc: &Document, v: &Dict) -> Option { v.get(b"Reference") .map(|r| doc.resolve(r)) .and_then(|r| r.as_array().cloned()) @@ -51,6 +51,31 @@ fn docmdp(doc: &Document, v: &Dict) -> Option { .map(|r| r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8) } +/// Whether the catalog's `/Perms /DocMDP` in `doc` is the signature dictionary `v`. +fn names_certification(doc: &Document, v: &Dict) -> bool { + let Some(root) = doc.root() else { return false }; + let catalog = doc.get(root); + let Some(perms) = catalog.as_dict().and_then(|c| c.get(b"Perms")).map(|p| doc.resolve(p)) else { return false }; + let Some(named) = perms.as_dict().and_then(|p| p.get(b"DocMDP")).map(|d| doc.resolve(d)) else { return false }; + named.as_dict() == Some(v) +} + +/// Set `info.certify` from `doc`, the revision signature value `v` covers. A document has at most +/// one certification, the signature its catalog's `/Perms /DocMDP` names (ISO 32000-2, 12.8.2.2), +/// so a DocMDP reference in any other signature dictionary does not choose the permissions later +/// changes are judged by. Such a signature is checked as an approval signature, with a note. +fn set_certification(doc: &Document, v: &Dict, info: &mut SignatureInfo) { + let asked = docmdp_reference(doc, v); + info.certify = asked.filter(|_| names_certification(doc, v)); + let note = Detail::plain( + "This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature.", + ); + info.details.retain(|d| *d != note); + if asked.is_some() && info.certify.is_none() { + info.details.push(note); + } +} + pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) { // A document timestamp can also be the value of a signature field (Documenso writes // `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the @@ -68,7 +93,7 @@ pub(super) fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: info.contact = text(doc, v, b"ContactInfo"); info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned()); info.signer = text(doc, v, b"Name"); - info.certify = docmdp(doc, v); + set_certification(doc, v, info); let invalid = |info: &mut SignatureInfo, why: Detail| { info.status = Status::Invalid; info.details.push(why); @@ -243,18 +268,24 @@ fn finish_validation(context: SignatureValidation<'_>, verified: VerifiedSignatu info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None } else { - // The permissions are the signed revision's: a later one can rewrite `/P` (#1788). This - // signature is the one whose byte range ends that revision. + // The permissions are the signed revision's: a later one can rewrite `/P` (#1788) or the + // catalog's `/Perms`. This signature is the one whose byte range ends that revision. A + // revision that holds no such signature does not say which permissions apply, so what + // came after it is judged as when the revision cannot be read: not permitted. let old = cache.revision(doc, bytes, covered); - if let Some(old) = &old - && let Some(v) = sig_fields(old) + let signed = old.as_ref().and_then(|old| { + sig_fields(old) .iter() .filter_map(|f| f.signature(old)) .find(|v| nums(old, v, b"ByteRange").is_some_and(|b| b.len() == 4 && b[2] + b[3] == covered as f64)) - { - info.certify = docmdp(old, &v); + }); + match (old, signed) { + (Some(old), Some(v)) => { + set_certification(&old, &v, info); + classify_changes(doc, Some(old), info.certify) + } + _ => classify_changes(doc, None, info.certify), } - classify_changes(doc, old, info.certify) }; let mut problems = false; // The changes are handed over as the words they are, not stitched into the sentence, so an diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index de55fdca6c..e765941c9a 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -538,6 +538,79 @@ fn a_document_timestamp_keeps_a_no_changes_certification_valid() { assert!(matches!(&s.modification, Modification::Allowed(k) if k.contains(&kind::SIGNATURE)), "{:?}", s.modification); } +/// Signature field "Approval" whose value, object 4, asks for DocMDP P 1 in its `/Reference`; +/// object 5 is another signature dictionary. `perms` goes into the catalog. The signature itself +/// is a dummy: which signature certifies is decided before its bytes are checked. +fn certification_of(perms: &str) -> pdfcraft_sign::SignatureInfo { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [3 0 R] >> {perms} >>"); + let bytes = write_pdf(&[ + catalog.as_bytes(), + b"<< /Type /Pages /Kids [] /Count 0 >>", + b"<< /FT /Sig /T (Approval) /V 4 0 R >>", + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /Reference [<< /Type /SigRef /TransformMethod /DocMDP /TransformParams << /Type /TransformParams /P 1 /V /1.2 >> >>] >>", + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached >>", + ]); + signatures(&open(&bytes), &bytes, &TrustStore { certs: Vec::new() }).into_iter().find(|s| s.field == "Approval").unwrap() +} + +/// Only the signature the catalog's `/Perms /DocMDP` names certifies the document: a DocMDP +/// reference in any other signature dictionary leaves it an approval signature, with a note. +#[test] +fn only_the_signature_the_catalog_names_certifies() { + let note = "This signature carries certification permissions, but the document does not name it as its certification, so it is checked as an approval signature."; + let noted = |s: &pdfcraft_sign::SignatureInfo| s.details.iter().filter(|d| d.message == note).count(); + let named = certification_of("/Perms << /DocMDP 4 0 R >>"); + assert_eq!((named.certify, noted(&named)), (Some(1), 0), "{:?}", named.details); + for perms in ["", "/Perms << /DocMDP 5 0 R >>", "/Perms << /UR3 4 0 R >>"] { + let s = certification_of(perms); + assert_eq!((s.certify, noted(&s)), (None, 1), "{perms:?}: {:?}", s.details); + } +} + +/// [`certified_p1`] with a fraction after its last `/ByteRange` number, signed again over the +/// edited bytes. The ranges still cover whole bytes, so the signature verifies, but no signature +/// in the signed revision has a byte range that ends exactly where that revision ends. +fn certified_p1_with_a_fractional_range() -> (TrustStore, Vec) { + let (id, trust, mut signed) = certified_p1(); + let prefix = b"/ByteRange ["; + let at = signed.windows(prefix.len()).position(|w| w == prefix).unwrap() + prefix.len(); + let end = at + signed[at..].iter().position(|b| *b == b']').unwrap(); + let text = std::str::from_utf8(&signed[at..end]).unwrap().to_owned(); + let range: Vec = text.split_ascii_whitespace().map(|n| n.parse().unwrap()).collect(); + let mut fractional = format!("{}.5", text.trim_end()).into_bytes(); + assert!(fractional.len() <= end - at, "no room for the fraction in {text:?}"); + fractional.resize(end - at, b' '); + signed[at..end].copy_from_slice(&fractional); + let (start, stop) = (range[1], range[2]); + signed[start + 1..stop - 1].fill(b'0'); + let alg = id.key.preferred_digest(); + let digest = alg.digest(&[&signed[..start], &signed[stop..]]); + let cms = pdfcraft_sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap(); + let hex: Vec = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + signed[start + 1..start + 1 + hex.len()].copy_from_slice(&hex); + (trust, signed) +} + +/// When the signed revision holds no signature whose byte range ends it, nothing signed says +/// which permissions apply: later changes are not permitted, whatever the latest catalog says. +#[test] +fn changes_after_an_unmatched_signed_revision_are_not_permitted() { + let (trust, signed) = certified_p1_with_a_fractional_range(); + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let s = check(&signed); + assert_eq!((s.status, &s.modification), (Status::Valid, &Modification::None), "{:?}", s.details); + let edited = edit_after(&signed, |doc| { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.remove(b"Perms"); + }) + .unwrap(); + add_comment(doc); + }); + let s = check(&edited); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::UNREADABLE]))); +} + #[test] fn invisible_signatures_and_refusals() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); From 3c26e2e3eb572e240a6f5cdfe7852d973297494c Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:50:55 +0200 Subject: [PATCH 07/29] Apply Redactions replaces the recovery snapshot at once (#6454) --- crates/engine/src/document.rs | 3 +- crates/engine/src/session/save.rs | 9 ++++ crates/ui-egui/src/dialogs.rs | 9 ++++ crates/ui-egui/src/persist/recovery.rs | 11 +++++ crates/ui-egui/tests/it/recovery.rs | 59 +++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 22667377c3..08654f6752 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -32,7 +32,8 @@ pub struct Document { 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`. + /// The generation last handed out by `autosave_snapshots`, or another one when storing that + /// snapshot failed (`autosave_failed`), so it is due again. pub(crate) snapshot_generation: u64, /// Why the document cannot be edited (e.g. encryption), if so. pub read_only_reason: Option, diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 34f4d4405d..a727ce6329 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -217,6 +217,15 @@ impl Session { out } + /// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the + /// document is due again, so the next autosave tries it again. + pub fn autosave_failed(&mut self, id: DocId) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + // Any generation but the current one leaves it due. + d.snapshot_generation = d.generation.wrapping_sub(1); + } + } + /// 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) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c4a49acd67..982878cb43 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1846,6 +1846,9 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { let which: Vec = app.hidden_draft.found.iter().filter(|f| f.2 && f.1 > 0).map(|f| f.0).collect(); let n: usize = app.hidden_draft.found.iter().filter(|f| f.2).map(|f| f.1).sum(); if app.apply_edit(Edit::RemoveHidden { which }) { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } if n == 1 { app.notify_tr("Removed 1 hidden item. Save to remove them from the file."); } else { @@ -1854,11 +1857,17 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { } } Some(Dialog::Sanitize) if app.apply_edit(Edit::Sanitize) => { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } app.notify_tr("Document sanitized. Save to finish: saving rewrites the whole file."); } Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { + if let Some((_, id)) = app.active_ids() { + app.renew_recovery(id); + } if marks == 1 { app.notify_tr("Applied 1 redaction mark. Save to remove the content from the file."); } else { diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs index f217781c07..34b6bdf76a 100644 --- a/crates/ui-egui/src/persist/recovery.rs +++ b/crates/ui-egui/src/persist/recovery.rs @@ -320,6 +320,9 @@ impl PdfCraftApp { RecoveryMeta { key, name: snap.name, path: snap.path, saved_at: now_secs(), encrypted: snap.encrypted, instance: store.instance() }; if let Err(e) = store.write(&meta, &snap.bytes) { log::warn!("autosave failed: {e}"); + // Still due: the next autosave writes it. After `renew_recovery` there is no + // older snapshot to fall back on. + self.session.autosave_failed(snap.doc); } } } @@ -331,6 +334,14 @@ impl PdfCraftApp { } } + /// Replace a document's recovery snapshot with its current state now. After an edit that takes + /// content out (Apply Redactions, Sanitize, Remove Hidden Information), the last snapshot still + /// holds what went, until the next autosave, and for good if the app is killed before then. + pub(crate) fn renew_recovery(&mut self, doc: pdfcraft_engine::DocId) { + self.forget_recovery(doc); + self.autosave_now(); + } + /// 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 diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 2d8258eeda..93ea045b6e 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -8,7 +8,7 @@ use egui::{Key, Modifiers}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_engine::Edit; -use pdfcraft_ui_egui::{PdfCraftApp, RecoveryStore}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, RecoveryStore}; fn fixture(n: usize) -> Vec { let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; @@ -131,6 +131,63 @@ fn saving_or_closing_clears_the_entry_and_nothing_is_written_for_clean_documents let _ = std::fs::remove_file(out); } +/// Whether any recovery snapshot in `s` holds `text`, in its bytes or in a decoded stream. +fn snapshot_holds(s: &RecoveryStore, text: &[u8]) -> bool { + let found = |data: &[u8]| data.windows(text.len()).any(|w| w == text); + s.list().iter().filter_map(|m| s.read(&m.key).ok()).any(|bytes| { + found(&bytes) + || pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).is_ok_and(|doc| { + doc.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| found(&d)))) + }) + }) +} + +/// Applying redactions replaces the recovery snapshot at once: the one autosaved before still holds +/// what the marks covered, until the next autosave, and for good after a crash. +#[test] +fn applying_redactions_replaces_the_recovery_snapshot_at_once() { + let s = store("redact"); + let mut h = harness(s.clone()); + h.state_mut().open_bytes("a.pdf", None, fixture(1)).unwrap(); + assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 })); + h.state_mut().autosave_now(); + assert!(snapshot_holds(&s, b"Confidential"), "the autosave before the redaction holds the page's text"); + // Redact pages: the current page, then apply. + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().dialog = Some(Dialog::RedactApply); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert!(!snapshot_holds(&s, b"Confidential"), "a recovery snapshot still holds the redacted text"); + assert_eq!(s.list().len(), 1, "the redacted document is autosaved in its place"); + s.clear(); +} + +/// An autosave that could not be written leaves the document due, so the next one writes it. +/// Renewing the snapshot after a redaction removes the old one first, so nothing else is left. +#[test] +fn a_failed_autosave_is_written_by_the_next_one() { + let s = store("retry"); + // A file where the folder belongs: the folder can't be made, so nothing can be written. + std::fs::write(s.dir(), b"").unwrap(); + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(s.clone()); + app.open_bytes("a.pdf", None, fixture(1)).unwrap(); + assert!(app.apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 })); + app.autosave_now(); + assert!(s.list().is_empty(), "the folder can't be made"); + std::fs::remove_file(s.dir()).unwrap(); + app.autosave_now(); + assert_eq!(s.list().len(), 1, "the snapshot that failed is written by the next autosave"); + s.clear(); +} + #[test] fn quitting_cleanly_leaves_nothing_behind() { let s = store("quit"); From f8cf35d0c058ba3464835aaf1cb0feca551bd312 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:51:10 +0200 Subject: [PATCH 08/29] Automation follows a path toward its root before looking anything up (#6455) Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> --- crates/automation/src/file_access.rs | 165 ++++++++++++++++++++------- crates/automation/src/lib.rs | 1 - crates/automation/src/paths.rs | 10 +- crates/automation/src/tests.rs | 23 ++++ 4 files changed, 154 insertions(+), 45 deletions(-) diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index ff91152567..dfc33b4e7d 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -5,10 +5,13 @@ //! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path //! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through: //! -//! - The path is made absolute against the working directory, as the file system reads it. Its -//! links are followed: for a file that doesn't exist yet, the links of its nearest existing -//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so -//! is a link that leads nowhere). +//! - The path is made absolute against the working directory, then followed the way the file +//! system reads it, one name at a time from the top, its links followed. A name is looked up +//! only while the folder it is in is the root, inside it, or one of the root's own parent +//! folders: a path that leaves those is refused before anything beyond is touched (looking up +//! `/net/` mounts it), and every such refusal reads the same, so it can't tell what exists +//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken +//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere). //! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`) //! than the root's is refused by name, before anything is resolved: resolving it would contact //! the host. @@ -20,7 +23,6 @@ //! folders in between (swapping in a link) can still win that race. The roots keep an agent to the //! files it was given; they don't guard against other programs on the computer. -use std::ffi::OsString; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -100,11 +102,10 @@ impl AutomationRoots { Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")), }; }; - let absolute = absolute(shown, target)?; - // Another network share or a device is refused by name, as `--root` refuses it: even - // resolving the path would contact the host (#4003). - if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) { - return Ok(()); + match follow(shown, &absolute(shown, target)?, folder) { + Toward::Inside => return Ok(()), + Toward::Refused(why) => return Err(why), + Toward::Outside => {} } let root = root_shown.display(); match access { @@ -141,42 +142,66 @@ fn absolute(shown: &str, path: &Path) -> Result { std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}")) } -/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing -/// folder's links followed and the rest appended. -fn resolve(shown: &str, absolute: &Path) -> Result { - // The names below the nearest existing folder, last first. - let mut missing: Vec = vec![]; - let mut at = absolute; - loop { - match std::fs::canonicalize(at) { - Ok(mut found) => { - found.extend(missing.iter().rev()); - return Ok(found); +/// Where a followed path ends up. +pub(crate) enum Toward { + /// Inside the root, with its links followed (a missing tail taken as names). + Inside, + /// Anywhere else. Said the same way whatever is out there. + Outside, + /// Inside the root, but it can't be followed, and why. + Refused(String), +} + +/// Follow the absolute `path` the way the file system reads it, one name at a time from the top, +/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own +/// parent folders (all with their links followed already, so `..` means what it means to the file +/// system). +pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward { + // Another network share or a device is refused by name, as `--root` refuses it: even looking + // it up would contact the host (#4003). + if foreign_share(path, root) { + return Toward::Outside; + } + let near = |at: &Path| inside(at, root) || inside(root, at); + let mut parts = path.components().peekable(); + let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect(); + let mut at = match std::fs::canonicalize(&top) { + Ok(at) if near(&at) => at, + _ => return Toward::Outside, + }; + while let Some(part) = parts.next() { + let name = match part { + Component::Normal(name) => name, + Component::CurDir => continue, + Component::ParentDir => { + at.pop(); + continue; } - Err(e) => match std::fs::symlink_metadata(at) { - Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")), - // There, yet its real path can't be had (no permission): nothing to compare. - Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), - }, - } - let parent = at.parent(); - match (at.components().next_back(), parent) { - (Some(Component::Normal(name)), Some(parent)) => { - missing.push(name.to_os_string()); - at = parent; - } - (Some(Component::ParentDir), _) => { - return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); - } - // Down to the root (or drive) and it doesn't exist either. - _ => { - let e = std::io::Error::from(std::io::ErrorKind::NotFound); - return Err(format!("automation path rejected: cannot resolve {shown}: {e}")); + Component::Prefix(_) | Component::RootDir => return Toward::Outside, + }; + let next = at.join(name); + match std::fs::canonicalize(&next) { + Ok(found) if near(&found) => at = found, + Ok(_) => return Toward::Outside, + // Not even there yet: only inside the root does it matter why. + Err(_) if !inside(&at, root) => return Toward::Outside, + Err(e) => { + return match std::fs::symlink_metadata(&next) { + Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) { + return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); + } + Toward::Inside + } + Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")), + }; } } } + if inside(&at, root) { Toward::Inside } else { Toward::Outside } } /// Is `path` (resolved) `root` or inside it, component by component (without regard to case on @@ -306,6 +331,62 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// Outside the root, the refusal comes before anything out there is looked up, so it can't + /// tell whether a folder out there exists. + #[test] + fn paths_out_of_the_root_are_refused_alike_without_looking() { + let (base, roots) = folders("follow"); + let sep = std::path::MAIN_SEPARATOR; + let root = s(base.join("in")); + let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, ""); + // Through a folder outside that exists, and through one that doesn't. + let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf"); + let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf"); + assert_eq!(refusal(&there), refusal(&missing)); + assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing)); + // Out through a missing folder and back in. (Windows folds `..` by name when it makes a path + // absolute, as it does when it opens one: that path is `in\a.pdf` there.) + #[cfg(not(windows))] + assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root")); + // A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it. + #[cfg(windows)] + { + let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}"); + assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf"))); + assert!( + refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"), + "{}", + refusal(&verbatim(r"..\nowhere\x.pdf")) + ); + } + let _ = std::fs::remove_dir_all(base); + } + + /// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on + /// macOS, where the temporary folder is reached through a link). + #[test] + fn a_root_may_be_named_as_given_or_as_resolved() { + let (base, roots) = folders("given"); + let resolved = std::fs::canonicalize(base.join("in")).unwrap(); + assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + /// A link beside the root, in one of its parent folders, is followed: to the root it leads in, + /// anywhere else it leads out. + #[cfg(unix)] + #[test] + fn links_beside_the_root_are_followed() { + let (base, roots) = folders("beside"); + std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap(); + assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(())); + assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + #[test] fn relative_paths_resolve_against_the_working_directory() { let (base, roots) = folders("relative"); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6132ac7416..28cbbfdca4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -48,7 +48,6 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -use file_access::foreign_share; use text_cache::TextCache; mod assemble; diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs index e13d0a1452..425cb3792e 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; +use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- @@ -39,7 +39,13 @@ impl Automation { let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { + // Followed one name at a time first: nothing outside the root or its own parent folders is + // looked up, not even to see whether it exists. What can't be followed inside the root is + // said below. Made absolute the way Windows reads it first: it drops a folder name's + // trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it + // doesn't. + let as_read = std::path::absolute(&joined).map_err(|_| outside())?; + if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) { return Err(outside()); } let mut existing = joined.as_path(); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index ad0aa039dc..acfa204b4b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -219,6 +219,7 @@ fn names_from_documents_become_one_plain_file_name() { #[cfg(windows)] #[test] fn other_shares_and_device_paths_are_refused_by_name() { + use crate::file_access::foreign_share; let local = Path::new(r"\\?\C:\work\root"); for p in [ r"\\host\share\x.pdf", @@ -242,6 +243,28 @@ fn other_shares_and_device_paths_are_refused_by_name() { assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared)); } +/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so +/// with a root, a path that names the root or a folder above it that way still reaches the file +/// inside. A dotted name outside stays outside. +#[cfg(windows)] +#[test] +fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() { + let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("root")).unwrap(); + std::fs::create_dir_all(base.join("outside")).unwrap(); + std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap(); + std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap(); + let a = Automation::new().with_root(base.join("root")).unwrap(); + let shown = base.to_string_lossy().into_owned(); + for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] { + let resolved = a.resolve(&p, false); + assert!(resolved.is_ok(), "{p}: {resolved:?}"); + } + assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err()); + let _ = std::fs::remove_dir_all(&base); +} + /// A fresh, empty folder for one staging test. fn staging_dir(test: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id())); From d0083c09a411b9056f86cadc48eaf980735c0f47 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:51:25 +0200 Subject: [PATCH 09/29] Links, actions and JavaScripts takes the scripts out of bookmarks (#6456) --- crates/redact/src/sanitize.rs | 113 +++++++++++++++++++++++++++++++++- crates/redact/src/tests.rs | 67 ++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..a4d1a26f4e 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -26,7 +26,7 @@ pub enum Hidden { /// Optional content (layers) that is off: its content, then the layers themselves. HiddenLayers, Bookmarks, - /// Links, actions (open action, additional actions) and document JavaScript. + /// Links, actions (open action, additional actions, what bookmarks run) and document JavaScript. LinksActionsScripts, /// Private data of other applications (`/PieceInfo`). PrivateData, @@ -173,6 +173,84 @@ fn outline_len_seen( n } +/// Takes out the action of outline item `d` when it does more than go to a place in this document +/// (a script, a launch, a web or remote link, a form submission); a jump with an action chained +/// after it (`/Next`) keeps only the jump. Items it links to that are written in place are cleaned +/// the same way, and items that are objects of their own go on `next`. Every link counts, not only +/// `/First` and `/Next`: a damaged outline may reach an item only backwards, and a full save keeps +/// that item too. `items` counts every item cleaned, inline ones included. Returns how many actions +/// went, or `None` past the depth limit or past [`OUTLINE_ITEM_BUDGET`] items. +fn clean_outline_item(doc: &Document, d: &mut Dict, next: &mut Vec, items: &mut usize, depth: usize) -> Option { + *items = items.saturating_add(1); + if depth > MAX_DIRECT_DEPTH || *items > OUTLINE_ITEM_BUDGET { + return None; + } + let mut n = 0; + if let Some(action) = d.get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()) { + match (action.name(b"S"), action.get(b"D")) { + (Some(b"GoTo"), _) if !action.contains(b"Next") => {} + (Some(b"GoTo"), Some(place)) => { + let mut jump = Dict::new(); + jump.set(b"S".to_vec(), Object::name("GoTo")); + jump.set(b"D".to_vec(), place.clone()); + d.set(b"A".to_vec(), Object::Dict(jump)); + n += 1; + } + _ => { + d.remove(b"A"); + n += 1; + } + } + } + for key in [&b"First"[..], b"Last", b"Next", b"Prev", b"Parent"] { + match d.get_mut(key) { + Some(Object::Ref(r)) => next.push(*r), + Some(Object::Dict(kid)) => n += clean_outline_item(doc, kid, next, items, depth + 1)?, + _ => {} + } + } + Some(n) +} + +/// [`clean_outline_item`] over the outline from the objects in `next`, after `items` items cleaned +/// already: the outline objects to write back, and how many actions went. Each object is read once; +/// `None` when the outline is larger than [`OUTLINE_ITEM_BUDGET`] items or nests in place past the +/// depth limit, so the removal can't be shown complete. +fn outline_without_actions(doc: &Document, mut next: Vec, mut items: usize) -> Option<(Vec<(ObjRef, Dict)>, usize)> { + let (mut out, mut n, mut seen) = (Vec::new(), 0, std::collections::HashSet::new()); + while let Some(r) = next.pop() { + if !seen.insert(r) { + continue; + } + if seen.len() > OUTLINE_ITEM_BUDGET { + return None; + } + let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; + let went = clean_outline_item(doc, &mut d, &mut next, &mut items, 0)?; + if went > 0 { + out.push((r, d)); + n += went; + } + } + Some((out, n)) +} + +/// How many outline actions Links, actions and JavaScripts would take out ([`clean_outline_item`]). +/// An outline too large or too deep to follow counts as one, so the removal runs and refuses it. +fn outline_action_count(doc: &Document, cat: &Dict) -> usize { + let (mut next, mut items) = (Vec::new(), 0); + let mut n = match cat.get(b"Outlines") { + Some(Object::Ref(r)) => { + next.push(*r); + 0 + } + Some(Object::Dict(o)) => clean_outline_item(doc, &mut o.clone(), &mut next, &mut items, 0).unwrap_or(1), + _ => return 0, + }; + n += outline_without_actions(doc, next, items).map_or(1, |(_, went)| went); + n +} + /// Entries that hold a category's data wherever they appear, not only on the catalog and pages: /// XMP on images, fonts and Forms, private data on Forms, associated files on pages and /// annotations, additional actions on fields. @@ -502,6 +580,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::LinksActionsScripts) + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + + outline_action_count(doc, &cat) } Hidden::PrivateData => owned(Hidden::PrivateData), }; @@ -510,6 +589,34 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { .collect() } +/// Take from the outline of catalog `cat` every action that does more than go to a place in this +/// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what +/// they run. An outline too large or nested too deep to follow is refused, not left half cleaned: +/// neither `cat` nor `doc` changes until the whole outline is cleaned. +fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), RedactError> { + let (mut next, mut items) = (Vec::new(), 0); + let root = match cat.get(b"Outlines") { + Some(Object::Dict(o)) => { + let mut o = o.clone(); + clean_outline_item(doc, &mut o, &mut next, &mut items, 0).ok_or(RedactError::TooDeep)?; + Some(o) + } + Some(Object::Ref(r)) => { + next.push(*r); + None + } + _ => None, + }; + let (objs, _) = outline_without_actions(doc, next, items).ok_or(RedactError::TooDeep)?; + if let Some(o) = root { + cat.set(b"Outlines".to_vec(), Object::Dict(o)); + } + for (r, d) in objs { + doc.set(r, Object::Dict(d)); + } + Ok(()) +} + /// Remove the chosen categories. Returns what was removed per category. /// /// # Errors @@ -628,6 +735,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { // The off layers leave the configuration (their content is gone already). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index d038c94c93..69bab777ff 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2853,6 +2853,73 @@ fn a_remove_hidden_copy_gets_a_new_identifier() { assert_ne!(ids[1], vec![0x22; 16], "the copy keeps the source's second identifier string"); } +/// Links, actions and JavaScripts takes the scripts out of bookmarks that stay: an item that runs +/// JavaScript loses its action, a jump with a script chained after it keeps only the jump, and a +/// plain jump is left alone. +#[test] +fn bookmarks_that_stay_lose_their_scripts() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b""), + b"<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>".to_vec(), + // 6 runs a script, 7 jumps and then runs one, 8 only jumps; 9 the script + b"<< /Title (Runs) /Parent 5 0 R /Next 7 0 R /A 9 0 R >>".to_vec(), + b"<< /Title (Jumps then runs) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >> >>".to_vec(), + b"<< /Title (Jumps) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [3 0 R /Fit] >> >>".to_vec(), + b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 2))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 2)]); + let item = |n: u32| doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap(); + assert!(!item(6).contains(b"A"), "{:?}", item(6)); + let jump = |n: u32| item(n).get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()).unwrap(); + assert_eq!((jump(7).name(b"S"), jump(7).contains(b"D"), jump(7).contains(b"Next")), (Some(&b"GoTo"[..]), true, false)); + assert_eq!(jump(8).name(b"S"), Some(&b"GoTo"[..])); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.windows(7).any(|w| w == b"(Jumps)"), "the bookmarks stay"); + assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "the bookmark's script survived the save"); +} + +/// A damaged outline may reach an item only backwards (`/Last`, `/Prev`, `/Parent`). The full save +/// keeps that item all the same, so its script goes too. +#[test] +fn bookmarks_reached_only_backwards_lose_their_scripts() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + // /First and /Next reach only 6; 7, 8 and 9 hang off its /Last, /Prev and /Parent and share script 10 + let mut doc = outlined_document(vec![ + b"<< /Title (Reached) /Last 7 0 R /Prev 8 0 R /Parent 9 0 R >>".to_vec(), + b"<< /Title (Last) /A 10 0 R >>".to_vec(), + b"<< /Title (Prev) /A 10 0 R >>".to_vec(), + b"<< /Title (Parent) /A 10 0 R >>".to_vec(), + b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 3))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 3)]); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.windows(8).any(|w| w == b"(Parent)"), "the bookmarks stay"); + assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "a script reached only backwards survived the save"); +} + +/// Outline items written in place count towards the outline budget too: 60,000 items with an inline +/// child each are 120,000 items, so the outline is refused rather than walked past the budget. +#[test] +fn an_outline_past_the_budget_with_inline_items_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let siblings = 60_000; + let mut items = Vec::with_capacity(siblings); + for i in 0..siblings { + let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; + items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes()); + } + let mut doc = outlined_document(items); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 1))); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} + /// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags /// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the /// annotations, the URL and the comment text included, while the page no longer lists them. From 34f9bea0add687ff54c65da5292665d00bbb6ada Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:51:40 +0200 Subject: [PATCH 10/29] Private data takes OPI links and alternate images (#6457) --- crates/redact/src/sanitize.rs | 18 +++++++++++++----- crates/redact/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index a4d1a26f4e..7661b83ddb 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -28,7 +28,8 @@ pub enum Hidden { Bookmarks, /// Links, actions (open action, additional actions, what bookmarks run) and document JavaScript. LinksActionsScripts, - /// Private data of other applications (`/PieceInfo`). + /// Private data of other applications (`/PieceInfo`), and prepress data on images and Forms + /// (`/OPI`, `/Alternates`). PrivateData, } @@ -253,9 +254,16 @@ fn outline_action_count(doc: &Document, cat: &Dict) -> usize { /// Entries that hold a category's data wherever they appear, not only on the catalog and pages: /// XMP on images, fonts and Forms, private data on Forms, associated files on pages and -/// annotations, additional actions on fields. -const OWNED: [(Hidden, &[u8]); 4] = - [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; +/// annotations, additional actions on fields. Images and Forms also carry prepress data: an OPI +/// proxy's link to the original file, and alternate images, which can be the unedited original. +const OWNED: [(Hidden, &[u8]); 6] = [ + (Hidden::Metadata, b"Metadata"), + (Hidden::PrivateData, b"PieceInfo"), + (Hidden::PrivateData, b"OPI"), + (Hidden::PrivateData, b"Alternates"), + (Hidden::Attachments, b"AF"), + (Hidden::LinksActionsScripts, b"AA"), +]; /// Keys whose dictionary values map names a producer chooses to objects (resources, glyph /// procedures, named destinations, role and class maps, private data by application). A name @@ -542,7 +550,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { // A graph nested too deeply to walk counts as holding everything, so the removal runs and // refuses it. let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); - let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); + let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::(); let mut doc2 = doc.clone(); HIDDEN .into_iter() diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 69bab777ff..804e3d26f6 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4580,3 +4580,29 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } + +/// Private data takes an image's prepress data too: its OPI link to the original file and its +/// alternate images, which can be the unedited original. +#[test] +fn private_data_takes_opi_links_and_alternate_images() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R /Fm 6 0 R >> >> >>".to_vec(), + stream("", b"q 50 0 0 50 20 20 cm /Im Do Q /Fm Do"), + stream( + "/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Alternates [<< /Image 7 0 R /DefaultForPrinting true >>]", + b"SHOWNIMAGE!", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /OPI << /1.3 << /Type /OPI /Version 1.3 /F (OPISERVERPATH.tif) >> >>", b""), + stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"ALTERNATEORIGINAL"), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::PrivateData), Some((Hidden::PrivateData, 2))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::PrivateData]).unwrap(), [(Hidden::PrivateData, 2)]); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"SHOWNIMAGE!"), "the image the page shows stays"); + assert!(!has(b"ALTERNATEORIGINAL"), "the alternate image survived the save"); + assert!(!has(b"OPISERVERPATH"), "the OPI link survived the save"); +} From d76bbd28f72777d4d19337b3baf39b66636c5ee7 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:52:13 +0200 Subject: [PATCH 11/29] Page copies bring only the form fields on the pages copied (#6461) --- crates/organize/src/import.rs | 37 ++++++++++++-- crates/organize/src/import/copy.rs | 77 ++++++++++++++++++++++++++++-- crates/organize/src/tests.rs | 71 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 7 deletions(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 636ed61991..6ec4d3bd92 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -10,7 +10,9 @@ //! - link and GoTo destinations that target a copied page point at the copy. Links to pages that //! were not copied are dropped, so they don't dangle; //! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the -//! destination `/AcroForm`; +//! destination `/AcroForm`. When only some of a document's pages are copied, a field brings only +//! the kids that lead to a widget on those pages: the rest of the form stays behind with its +//! values; //! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the //! source structure tree is not copied; //! - named destinations are resolved against the source's name tree and become explicit ones; @@ -18,6 +20,7 @@ //! `/OCProperties`, keeping their default on/off state. use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -34,8 +37,8 @@ pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, }; -use copy::SKIP_ON_PAGE; pub(crate) use copy::{Copier, copy_object}; +use copy::{MAX_COPY_DEPTH, SKIP_ON_PAGE}; use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; pub use fields::{combine_field_conflicts, field_name_conflicts}; @@ -171,6 +174,27 @@ struct Imported { renamed_fields: Vec<(String, String)>, } +/// The annotations on `pages` and, above each widget among them, its fields up to the top one. +/// A field copied with those pages keeps only these kids: its other kids sit on pages left behind, +/// and carry their own values and appearances. +fn fields_on(src: &Document, pages: impl IntoIterator) -> HashSet { + let mut kept = HashSet::new(); + for page in pages { + let page = src.get(page); + let Some(annots) = page.as_dict().and_then(|d| d.get(b"Annots")).map(|a| src.resolve(a)) else { continue }; + for a in annots.as_array().into_iter().flatten() { + let mut at = a.as_ref().or_else(|| src.resolve(a).as_dict().and_then(|d| d.reference(b"Parent"))); + // Up the field tree, as deep as a copy may go, and once: a node seen already has its + // fields above it in the set. + for _ in 0..MAX_COPY_DEPTH { + let Some(r) = at.filter(|r| kept.insert(*r)) else { break }; + at = src.get(r).as_dict().and_then(|d| d.reference(b"Parent")); + } + } + } + kept +} + /// `import_pages`, also returning what was copied from where. fn import_pages_mapped( dst: &mut Document, @@ -199,6 +223,9 @@ fn import_pages_mapped( (*page, new, inherited.clone()) }) .collect(); + if src_pages.iter().collect::>().len() < source.len() { + copier.kept = Some(Rc::new(fields_on(src, copier.pages.keys().copied()))); + } let mut annot_pages = HashMap::new(); let mut new_pages = Vec::new(); let mut done: HashMap = HashMap::new(); @@ -208,7 +235,11 @@ fn import_pages_mapped( // popup, a widget's place). Destinations still map to the pages copied. let repeat = done.contains_key(&page); let new = if repeat { dst.add(Object::Null) } else { new }; - let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone())); + let mut own = repeat.then(|| { + let mut c = Copier::new(src, HashMap::new(), copier.pages.clone()); + c.kept = copier.kept.clone(); + c + }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 5e4be54150..8fed2112b6 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,6 @@ -use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; +use std::rc::Rc; + +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. @@ -10,7 +12,7 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// Real documents stay well under this (a page's resources, fonts and descriptors, nested /// form XObjects and field trees are a few levels each); a chain of thousands of indirect /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; +pub(super) const MAX_COPY_DEPTH: usize = 256; /// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's /// appearance, placement and page. Field state (name, value, flags, default appearance) moves to @@ -56,11 +58,15 @@ pub(crate) struct Copier<'a> { pub(super) fields: Vec, /// Optional content groups copied: (source, destination). pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, + /// When only some pages are copied: the annotations on them and the fields above each, the + /// only kids a copied field keeps (see `fields_on`). `None` keeps every kid. Shared, so the + /// copiers made for a repeated page or an action don't each copy it. + pub(super) kept: Option>>, } impl<'a> Copier<'a> { pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new(), kept: None } } /// Copy an indirect object (once), returning its destination reference. @@ -93,6 +99,9 @@ impl<'a> Copier<'a> { if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { return Ok(o.clone()); } + if self.acts_on_none(o) { + return Ok(Object::Null); + } self.descend()?; let copied = match o { Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), @@ -112,6 +121,8 @@ impl<'a> Copier<'a> { fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + // A form action lists the fields it submits, resets or hides. + let lists_fields = matches!(d.name(b"S"), Some(b"SubmitForm" | b"ResetForm" | b"Hide")); let mut out = Dict::new(); for (k, v) in d.iter() { if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { @@ -122,6 +133,34 @@ impl<'a> Copier<'a> { out.set(k.clone(), v.clone()); continue; } + if is_field + && k.as_slice() == b"Kids" + && let Some(kept) = &self.kept + { + let kids: Vec = self + .src + .resolve(v) + .as_array() + .into_iter() + .flatten() + .filter(|kid| kid.as_ref().is_none_or(|r| kept.contains(&r))) + .cloned() + .collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(kids), None)?); + continue; + } + // Of some of the pages, an action lists only the fields on them: one left behind would + // come along with its value. Fields named by text stay. + if lists_fields + && matches!(k.as_slice(), b"Fields" | b"T") + && let Some(kept) = &self.kept + { + let listed = self.src.resolve(v); + let entries = listed.as_array().cloned().unwrap_or_else(|| vec![v.clone()]); + let entries: Vec = entries.into_iter().filter(|e| e.as_ref().is_none_or(|r| kept.contains(&r))).collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(entries), None)?); + continue; + } out.set(k.clone(), self.copy_value(dst, v, None)?); } if let Some(this) = this { @@ -136,6 +175,34 @@ impl<'a> Copier<'a> { Ok(out) } + /// Whether `o` is a reset or submit action that names only fields left behind (see `kept`). + /// Its copy would list none, and readers (ours too) may take an empty list for every field, + /// which is what an omitted one means (ISO 32000-2 §12.7.6). So it is not copied, nor are the + /// actions after it, as with a link to a page left behind. A list of fields to leave out + /// (Include/Exclude flag set) loses nothing. + fn acts_on_none(&self, o: &Object) -> bool { + let Some(kept) = &self.kept else { return false }; + let held; + let action = match o { + // One copied already is not such an action, and needn't be fetched again. + Object::Ref(r) if !self.map.contains_key(r) => { + held = self.src.resolve(o); + held.as_dict() + } + Object::Dict(d) => Some(d), + _ => None, + }; + let Some(a) = action.filter(|a| matches!(a.name(b"S"), Some(b"ResetForm" | b"SubmitForm"))) else { return false }; + if a.get(b"Flags").and_then(|f| self.src.resolve(f).as_int()).is_some_and(|f| f & 1 != 0) { + return false; + } + let Some(v) = a.get(b"Fields") else { return false }; + let listed = self.src.resolve(v); + // As `copy_dict` reads the list: a lone entry stands for a list of one. + let entries = listed.as_array().map_or(std::slice::from_ref(v), Vec::as_slice); + !entries.is_empty() && entries.iter().all(|e| e.as_ref().is_some_and(|r| !kept.contains(&r))) + } + /// Map a source destination (explicit array or reference to one) to the destination /// document; `None` if it targets a page that was not copied. pub(super) fn map_dest(&self, dest: &Object) -> Option { @@ -196,11 +263,13 @@ impl<'a> Copier<'a> { d.remove(b"A"); } } + } else if self.acts_on_none(&action) { + d.remove(b"A"); } else { // Other actions (URI, Launch, JavaScript…) carry no page references. The // copier lends its map out and takes it back with the new copies in it, // so actions sharing an object share its one copy. - let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let mut c = Copier { kept: self.kept.clone(), ..Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()) }; let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); self.map = c.map; d.set(b"A".to_vec(), copied?); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7eb677a84c..593efd2600 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4241,3 +4241,74 @@ fn an_outline_deeper_than_the_listing_is_exported_as_truncated() { crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); assert_eq!(crate::bookmarks(&target).len(), 33); } + +/// Does any object in `doc` hold the string value `/V (value)`? +fn holds_value(doc: &Document, value: &str) -> bool { + doc.scan_objects() + .into_iter() + .any(|(_, o)| o.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string()).is_some_and(|v| v.to_text() == value)) +} + +/// A field with a widget on each of two pages: extracting one page brings that widget and the field +/// above it, not the other kid with its value. +#[test] +fn extracting_a_page_leaves_the_rest_of_its_form_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [8 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [9 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /T (applicant) /Kids [8 0 R 9 0 R] >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Parent 7 0 R /P 3 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /Parent 7 0 R /P 4 0 R /Rect [0 0 50 20] >>".into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(holds_value(&part, "Ada")); + assert!(!holds_value(&part, "90000"), "the widget on the page left behind came along with its value"); + let form = part.resolve(catalog(&part).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let fields = part.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let applicant = part.resolve(&fields[0]).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(applicant.get(b"Kids").unwrap()).as_array().map(Vec::len), Some(1)); + // With every page, the whole form comes along, as before. + let both = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert!(holds_value(&both, "Ada") && holds_value(&both, "90000")); +} + +/// A form action lists fields by reference: extracting the page with the button doesn't bring +/// the fields it submits, resets or hides from a page left behind, with their values. A reset of +/// only those would list none, which reads as every field: it goes. +#[test] +fn a_form_action_brings_no_field_from_a_page_left_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R 8 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [7 0 R 9 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [8 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (send) /P 3 0 R /Rect [0 0 50 20] \ + /A << /S /SubmitForm /F (https://example.invalid/) /Fields [8 0 R (other)] \ + /Next << /S /ResetForm /Fields [8 0 R] /Next << /S /Hide /T 8 0 R >> >> >> >>" + .into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /P 4 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (clear) /P 3 0 R /Rect [0 30 50 50] \ + /A << /S /ResetForm /Fields [8 0 R] >> /AA << /U << /S /ResetForm /Flags 1 /Fields [8 0 R] >> >> >>" + .into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(!holds_value(&part, "90000"), "a field the action lists came along from the page left behind"); + let submit = part.resolve(annots(&part, 0)[0].get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(submit.get(b"Fields").unwrap()).as_array().map(Vec::len), Some(1), "the field named by text stays"); + assert!(submit.get(b"Next").is_none_or(|n| part.resolve(n).as_dict().is_none()), "the reset after the submit would clear every field"); + let clear = annots(&part, 0).remove(1); + assert!(clear.get(b"A").is_none(), "the clear button would clear every field"); + // A reset of every field but the one left behind still resets them all. + let aa = part.resolve(clear.get(b"AA").unwrap()).as_dict().cloned().unwrap(); + assert!(aa.get(b"U").is_some_and(|u| part.resolve(u).as_dict().is_some())); + // With every page, the actions still list it. + assert!(holds_value(&full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()), "90000")); +} From 25aef86a1d2194b398c612ae8c1aa3156b85a7a5 Mon Sep 17 00:00:00 2001 From: quincy-jc Date: Mon, 9 Nov 2026 11:53:12 +1100 Subject: [PATCH 12/29] feat(reduce): ask where to save before compressing (#6229) --- crates/ui-egui/src/platform/pickers.rs | 140 +++++++++++++++++++- crates/ui-egui/src/workflows/create_ui.rs | 103 +++++++++++--- crates/ui-egui/src/workflows/optimize_ui.rs | 21 ++- parity/acrobat-features.toml | 4 +- 4 files changed, 239 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/platform/pickers.rs b/crates/ui-egui/src/platform/pickers.rs index c680eaeaba..bd3aa568dc 100644 --- a/crates/ui-egui/src/platform/pickers.rs +++ b/crates/ui-egui/src/platform/pickers.rs @@ -469,7 +469,7 @@ mod tests { app.pick_override = Some(vec![out.to_string_lossy().into_owned()]); // Save As is still open on another tab when the optimizer finishes. app.pickers.showing.store(true, Ordering::SeqCst); - app.save_optimized(doc, crate::optimize_ui::OptimizeKind::Reduce, Ok((reduced.clone(), String::new()))); + app.save_optimized(doc, crate::optimize_ui::OptimizeKind::Reduce, Ok((reduced.clone(), String::new())), None); app.pickers.deliver(None, vec![], Box::new(|_, _| {})); app.process_picked(); app.process_picked(); @@ -482,6 +482,144 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// An app with a document that compresses, and a folder for its copy. + fn reducing(tag: &str) -> (PdfCraftApp, PathBuf) { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-pickers-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.pdf", None, text_pdf("Minutes of the harbour board")).unwrap(); + (app, dir) + } + + /// Reduce File Size asks first, so a heavy file can finish by itself. + #[test] + fn reduce_file_size_asks_where_to_save_before_it_compresses() { + let (mut app, dir) = reducing("first"); + let out = dir.join("notes (reduced).pdf"); + app.pick_override = Some(vec![out.to_string_lossy().into_owned()]); + app.reduce_file_size(); + assert!(app.optimize_run.is_none() && app.progress_notice.is_none(), "nothing runs until the answer is in"); + assert!(app.pickers.pending(), "the question is out"); + app.process_picked(); + let run = app.optimize_run.as_ref().expect("the answer starts it"); + assert_eq!(run.destination.as_deref(), out.to_str()); + // Nothing more is asked at the end: the copy is simply there. + app.pick_override = Some(vec![dir.join("not asked.pdf").to_string_lossy().into_owned()]); + let start = std::time::Instant::now(); + while app.optimize_run.is_some() && start.elapsed() < std::time::Duration::from_secs(30) { + app.poll_optimize(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!app.pickers.pending(), "it doesn't ask again"); + let written = std::fs::read(&out).unwrap_or_default(); + let mut again = pdfcraft_engine::Session::new(); + let id = again.open("notes (reduced).pdf", None, Arc::new(written), None).unwrap(); + let text = pdfcraft_engine::export::Exporter::new(again.get(id).unwrap()).text(0).unwrap(); + assert!(text.contains("Minutes of the harbour board"), "the copy reopens with its text: {text:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn cancelling_reduce_file_size_compresses_nothing() { + let (mut app, dir) = reducing("cancel"); + app.pick_override = Some(vec![]); + app.reduce_file_size(); + app.process_picked(); + assert!(app.optimize_run.is_none() && app.progress_notice.is_none()); + assert!(!app.pickers.pending()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing was written"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The answer is for the document it was asked about. + #[test] + fn switching_documents_while_reduce_file_size_asks_compresses_nothing() { + let (mut app, dir) = reducing("switch"); + let out = dir.join("notes (reduced).pdf"); + app.pick_override = Some(vec![out.to_string_lossy().into_owned()]); + app.run_inline = true; + app.reduce_file_size(); + app.open_bytes("other.pdf", None, text_pdf("Another document")).unwrap(); + app.process_picked(); + assert!(app.optimize_run.is_none() && !out.exists(), "nothing runs, and nothing is written"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Minutes of work aren't lost because the place chosen first has gone bad. + #[test] + fn a_reduced_copy_that_cant_be_written_where_chosen_asks_again() { + let (mut app, dir) = reducing("again"); + let (gone, good) = (dir.join("gone").join("notes (reduced).pdf"), dir.join("notes (reduced).pdf")); + app.pick_override = Some(vec![gone.to_string_lossy().into_owned()]); + app.run_inline = true; + app.reduce_file_size(); + app.pick_override = Some(vec![good.to_string_lossy().into_owned()]); + app.process_picked(); + let told = app.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(told.contains("Couldn't write"), "{told}"); + assert!(app.pickers.pending(), "it asks again"); + app.process_picked(); + assert!(good.exists(), "the copy is saved at the second place"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// However many places turn out bad, the copy waits for one that works. + #[test] + fn a_reduced_copy_asks_until_it_can_be_written() { + let (mut app, dir) = reducing("until"); + let bad = |n: &str| dir.join(n).join("notes (reduced).pdf").to_string_lossy().into_owned(); + let good = dir.join("notes (reduced).pdf"); + app.pick_override = Some(vec![bad("gone")]); + app.run_inline = true; + app.reduce_file_size(); + app.pick_override = Some(vec![bad("gone too")]); + app.process_picked(); + assert!(app.pickers.pending(), "it asks after the first"); + app.pick_override = Some(vec![good.to_string_lossy().into_owned()]); + app.process_picked(); + assert!(app.pickers.pending(), "and after the second"); + app.process_picked(); + assert!(good.exists(), "the copy is saved at the third place"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A file that's chosen and then has nothing to replace it stays as it was. + #[test] + fn a_reduced_copy_that_isnt_smaller_leaves_the_chosen_file_alone() { + let (mut app, dir) = reducing("small"); + let out = dir.join("existing.pdf"); + std::fs::write(&out, b"what was there").unwrap(); + app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap(); + app.pick_override = Some(vec![out.to_string_lossy().into_owned()]); + app.run_inline = true; + app.reduce_file_size(); + app.process_picked(); + assert_eq!(std::fs::read(&out).unwrap(), b"what was there"); + let told = app.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(told.contains("No copy was saved"), "{told}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// While a run is going, nothing is asked that couldn't be used. + #[test] + fn reduce_file_size_doesnt_ask_while_a_run_is_going() { + let (mut app, dir) = reducing("busy"); + let (_, doc) = app.active_ids().unwrap(); + app.optimize_run = Some(crate::optimize_ui::OptimizeRun { + doc, + kind: crate::optimize_ui::OptimizeKind::Reduce, + destination: None, + progress: Arc::default(), + }); + app.pick_override = Some(vec![dir.join("no.pdf").to_string_lossy().into_owned()]); + app.reduce_file_size(); + assert!(!app.pickers.pending(), "no question"); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_follow_up_that_asks_again_gets_its_picker_before_a_request_that_came_first() { let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index 1a7f6222aa..f74ed10535 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -248,21 +248,52 @@ impl PdfCraftApp { } /// Reduce File Size: write a compacted copy with images downsampled (the open document is - /// unchanged). It runs in the background with a progress bar, like the PDF Optimizer. + /// unchanged). It asks where to save first, so a heavy file can finish by itself, then runs in + /// the background with a progress bar, like the PDF Optimizer. pub(crate) fn reduce_file_size(&mut self) { - self.start_optimize(crate::optimize_ui::OptimizeKind::Reduce, &pdfcraft_engine::optimize::Settings::default(), &[]); + use crate::optimize_ui::OptimizeKind::Reduce; + let settings = pdfcraft_engine::optimize::Settings::default(); + #[cfg(target_arch = "wasm32")] + self.start_optimize(Reduce, &settings, &[]); + #[cfg(not(target_arch = "wasm32"))] + { + // What can't run says so now, not after asking where to save. + if !self.commit_form_typing() { + return; + } + let Some((_, id)) = self.active_ids() else { return }; + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } + if let Err(e) = self.session.optimize_job(id, &settings, &[]) { + return self.save_optimized(id, Reduce, Err(e), None); + } + if let Some(path) = self.save_override.clone() { + return self.run_optimize(id, Reduce, &settings, &[], Some(path)); + } + let Some(doc) = self.session.get(id) else { return }; + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(optimized_name(&doc.name, Reduce)); + let dialog = crate::editing::in_document_folder(dialog, doc.path.as_deref()); + // With the target, a document closed, switched from or edited meanwhile isn't compressed. + self.ask_one(crate::pickers::Ask::Save(dialog), Some(id), move |app, path| { + app.run_optimize(id, Reduce, &settings, &[], Some(path.to_string_lossy().into_owned())); + }); + } } - /// Save an optimized copy (Reduce File Size, Optimize PDF) next to the original, reporting - /// the saving. A Reduce File Size copy that isn't smaller is not saved (#490). + /// Save an optimized copy (Reduce File Size, Optimize PDF) to `destination`, or where the user + /// now chooses, reporting the saving. A Reduce File Size copy that isn't smaller is not saved + /// (#490). pub(crate) fn save_optimized( &mut self, id: pdfcraft_engine::DocId, kind: crate::optimize_ui::OptimizeKind, result: Result<(Arc>, String), pdfcraft_engine::EditError>, + destination: Option, ) { let Some(doc) = self.session.get(id) else { return }; - let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix())); + let (before, name) = (doc.bytes.len(), optimized_name(&doc.name, kind)); #[cfg(not(target_arch = "wasm32"))] let folder = doc.path.clone(); let (bytes, detail) = match result { @@ -298,23 +329,57 @@ impl PdfCraftApp { }; #[cfg(not(target_arch = "wasm32"))] { - let write = move |app: &mut Self, path: String| match crate::editing::write_atomically(&path, &bytes) { - Ok(()) => saved(app, path), - Err(e) => app.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]), - }; - match self.save_override.clone() { - Some(p) => write(self, p), - None => { - let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name); - let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); - self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned())); - } + let chose_first = destination.is_some() && self.save_override.is_none(); + match destination.or_else(|| self.save_override.clone()) { + Some(path) => match crate::editing::write_atomically(&path, &bytes) { + Ok(()) => saved(self, path), + Err(e) => { + self.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]); + // The copy can have taken minutes: a place that has gone bad since doesn't lose it. + if chose_first { + self.ask_where_to_save_optimized(name, folder, bytes, saved); + } + } + }, + None => self.ask_where_to_save_optimized(name, folder, bytes, saved), } } #[cfg(target_arch = "wasm32")] - match crate::editing::download(&name, &bytes) { - Ok(()) => saved(self, name), - Err(e) => self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + { + let _ = destination; + match crate::editing::download(&name, &bytes) { + Ok(()) => saved(self, name), + Err(e) => self.notify_fmt("Couldn't download {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + } } } + + /// Ask where an optimized copy goes and write it there, asking again until it is written or + /// the question is cancelled. + #[cfg(not(target_arch = "wasm32"))] + fn ask_where_to_save_optimized( + &mut self, + name: String, + folder: Option, + bytes: Arc>, + saved: impl FnOnce(&mut Self, String) + Send + 'static, + ) { + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name); + let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); + self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| { + let path = p.to_string_lossy().into_owned(); + match crate::editing::write_atomically(&path, &bytes) { + Ok(()) => saved(app, path), + Err(e) => { + app.notify_fmt("Couldn't write {path}: {e}", &[("path", &path), ("e", &e.to_string())]); + app.ask_where_to_save_optimized(name, folder, bytes, saved); + } + } + }); + } +} + +/// The suggested name of an optimized copy of `document` ("notes (reduced).pdf"). +fn optimized_name(document: &str, kind: crate::optimize_ui::OptimizeKind) -> String { + format!("{} ({}).pdf", stem(document), kind.suffix()) } diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index fee4f7c42b..6c6bedad00 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -71,6 +71,8 @@ impl OptimizeKind { pub struct OptimizeRun { pub doc: DocId, pub kind: OptimizeKind, + /// Where the copy goes, if the command asked first. Otherwise it asks when the run is done. + pub destination: Option, pub progress: Arc>, } @@ -378,11 +380,16 @@ impl PdfCraftApp { /// Run an optimization of the active document on a worker thread (inline in tests and on /// the web), showing its progress; the copy is saved when it is done. pub(crate) fn start_optimize(&mut self, kind: OptimizeKind, settings: &Settings, discard: &[Hidden]) { + let Some((_, id)) = self.active_ids() else { return }; + self.run_optimize(id, kind, settings, discard, None); + } + + /// [`Self::start_optimize`] for document `id`, saving to `destination` if that's known already. + pub(crate) fn run_optimize(&mut self, id: DocId, kind: OptimizeKind, settings: &Settings, discard: &[Hidden], destination: Option) { // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; } - let Some((_, id)) = self.active_ids() else { return }; if self.optimize_run.is_some() { self.notify_tr("Optimization is already running"); return; @@ -394,7 +401,7 @@ impl PdfCraftApp { } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, - Err(e) => return self.save_optimized(id, kind, Err(e)), + Err(e) => return self.save_optimized(id, kind, Err(e), destination), }; let progress = Arc::new(Mutex::new(OptimizeProgress::default())); let p = progress.clone(); @@ -419,7 +426,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] work(); - self.optimize_run = Some(OptimizeRun { doc: id, kind, progress }); + self.optimize_run = Some(OptimizeRun { doc: id, kind, destination, progress }); self.poll_optimize(); } @@ -435,7 +442,7 @@ impl PdfCraftApp { /// Show progress; save the copy once the worker is done. pub(crate) fn poll_optimize(&mut self) { let Some(run) = self.optimize_run.as_ref() else { return }; - let (doc, kind, progress) = (run.doc, run.kind, run.progress.clone()); + let (doc, kind, destination, progress) = (run.doc, run.kind, run.destination.clone(), run.progress.clone()); let Ok(mut s) = progress.lock() else { // The worker panicked while holding the lock: it will never report back. self.optimize_run = None; @@ -464,7 +471,7 @@ impl PdfCraftApp { let detail = if kind == OptimizeKind::Advanced { summary(&r) } else { String::new() }; (b, detail) }); - self.save_optimized(doc, kind, result); + self.save_optimized(doc, kind, result, destination); } } } @@ -486,7 +493,7 @@ mod tests { app.set_option("language", "en").unwrap(); app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); let (_, doc) = app.active_ids().unwrap(); - app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Advanced, progress: p }); + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Advanced, destination: None, progress: p }); app.notify("Saved notes.pdf"); app }); @@ -519,7 +526,7 @@ mod tests { app.set_option("language", "en").unwrap(); app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); let (_, doc) = app.active_ids().unwrap(); - app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, destination: None, progress: Arc::default() }); app.optimize_with_draft(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 967a424d24..70a0cc34df 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7477,8 +7477,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/src/platform/pickers.rs::reduce_file_size_asks_where_to_save_before_it_compresses", "crates/ui-egui/src/platform/pickers.rs::cancelling_reduce_file_size_compresses_nothing", "crates/ui-egui/src/platform/pickers.rs::a_reduced_copy_that_cant_be_written_where_chosen_asks_again", "crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "the command asks where to save before it compresses, so a heavy file can finish by itself, and asks again if that place can't be written; images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" From d0b2a32744cfda8edd9dd9abb5b5c79f424a4cdd Mon Sep 17 00:00:00 2001 From: quincy-jc Date: Mon, 9 Nov 2026 11:53:27 +1100 Subject: [PATCH 13/29] perf(optimize): work out pictures on threads and skip needless page reads (#6230) --- Cargo.lock | 15 + crates/cos/src/budget.rs | 5 + crates/engine/src/optimizer.rs | 27 +- crates/engine/src/tests.rs | 18 +- crates/optimize/Cargo.toml | 4 + crates/optimize/README.md | 6 + crates/optimize/src/fragments.rs | 44 +- crates/optimize/src/images.rs | 406 ++++++++++++++---- crates/optimize/src/lib.rs | 7 + crates/optimize/src/pool.rs | 262 +++++++++++ crates/optimize/tests/jpeg_color_transform.rs | 16 +- crates/optimize/tests/optimize.rs | 141 +++++- crates/ui-egui/src/workflows/optimize_ui.rs | 22 +- 13 files changed, 870 insertions(+), 103 deletions(-) create mode 100644 crates/optimize/src/pool.rs diff --git a/Cargo.lock b/Cargo.lock index ea3a743bf8..5bcb1d80dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4592,6 +4592,7 @@ dependencies = [ "pdfcraft-render", "proptest", "thiserror 2.0.21", + "thread-priority", "zune-jpeg", ] @@ -6955,6 +6956,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread-priority" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "tiff" version = "0.11.3" diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 1a99885944..5271e591b7 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -209,6 +209,11 @@ impl ImageBudget { self.refused } + /// Charges `work` steps paid for out of another allowance, by a thread working on its own. + pub fn charge(&mut self, work: u64) { + self.work = self.work.saturating_sub(work); + } + /// The steps of decoding still allowed. pub fn work_left(&self) -> u64 { self.work diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 30a14c5695..1a741c0912 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -12,11 +12,12 @@ use pdfcraft_cos::{SaveOptions, write_full}; use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize}; /// Where an optimization is. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OptimizeStage { - /// Removing the user data chosen under Discard User Data. - #[default] + /// Removing the user data chosen under Discard User Data, if any was. Discarding, + /// Reading what the pages draw: `done` of `total` read. + Reading { done: usize, total: usize }, /// Merging the fragments of a page picture into one image. Fragments, /// About to process image `done` (0-based) of `total`. @@ -29,14 +30,21 @@ pub enum OptimizeStage { Writing, } +impl Default for OptimizeStage { + fn default() -> Self { + OptimizeStage::Reading { done: 0, total: 0 } + } +} + impl OptimizeStage { - /// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are - /// small fixed steps. + /// How far along the whole run is, from 0 to 1, weighed by where the time goes: reading pages + /// and pictures. The rest are small fixed steps. pub fn fraction(self) -> f32 { + let part = |done: usize, total: usize| if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }; match self { - OptimizeStage::Discarding => 0.0, - OptimizeStage::Fragments => 0.03, - OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, + OptimizeStage::Discarding | OptimizeStage::Fragments => 0.0, + OptimizeStage::Reading { done, total } => 0.25 * if total == 0 { 0.0 } else { part(done, total) }, + OptimizeStage::Images { done, total } => 0.25 + 0.55 * part(done, total), OptimizeStage::CleaningUp => 0.8, OptimizeStage::Merging => 0.85, OptimizeStage::Writing => 0.9, @@ -91,10 +99,10 @@ impl OptimizeJob { pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; - step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { + step(OptimizeStage::Discarding)?; // Every ticked category, as `remove_hidden` decides between them: with form fields // ticked, discarding hidden text also takes the hidden text the flattened appearances // bring onto the page, which no scan before flattening counts. It refuses when its scan @@ -112,6 +120,7 @@ impl OptimizeJob { }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { + optimize::Stage::Reading { done, total } => OptimizeStage::Reading { done, total }, optimize::Stage::Fragments => OptimizeStage::Fragments, optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, optimize::Stage::CleanUp => OptimizeStage::CleaningUp, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index efb196de56..2d8eab60db 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4083,7 +4083,9 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { }) .unwrap(); assert!(bytes.starts_with(b"%PDF-")); - assert_eq!(stages.first(), Some(&OptimizeStage::Discarding)); + assert_eq!(stages.first(), Some(&OptimizeStage::Reading { done: 0, total: 2 }), "it says what it does first"); + assert!(!stages.contains(&OptimizeStage::Discarding), "nothing was chosen to discard: {stages:?}"); + assert!(stages.iter().any(|st| matches!(st, OptimizeStage::Images { .. })), "{stages:?}"); assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]); let fractions: Vec = stages.iter().map(|st| st.fraction()).collect(); assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}"); @@ -4094,6 +4096,20 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { "no images: no division by zero" ); + // What the bar shows before anything is reported says nothing of user data either. + assert_eq!(OptimizeStage::default(), OptimizeStage::Reading { done: 0, total: 0 }); + assert_eq!(OptimizeStage::default().fraction(), 0.0); + assert!(OptimizeStage::Reading { done: 2, total: 4 }.fraction() > 0.0, "reading pages moves the bar"); + + // Discarding says so when something is. This document has no metadata, which only stops it after + // the stage is reported. + let mut with_discard = Vec::new(); + let _ = s.optimize_job(id, &settings, &[crate::Hidden::Metadata]).unwrap().run(|st| { + with_discard.push(st); + true + }); + assert_eq!(with_discard.first(), Some(&OptimizeStage::Discarding)); + // Cancelled at the merge: nothing is written, the open document is untouched. let before = s.get(id).unwrap().bytes.clone(); let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging); diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index d1a759d468..dd9802e303 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -22,6 +22,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } # The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0). zune-jpeg = "0.5" +# Lets other programs go first while a copy is being made. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +thread-priority = "3.1" + [dev-dependencies] pdfcraft-render = { workspace = true } proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index fd7c60ae85..4c0549b1c8 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -121,6 +121,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` counts those the image step read. Streams it only stores with Flate (below) aren't counted. +- **Threads**: pictures are worked out on up to half the machine's processors (at most 8, one on + the web), and as many as 1 GiB holds of the largest picture. Those threads go at low priority + where the system allows it, so other programs go first; pictures done on the calling thread keep + its priority. The document only changes in order on one thread, and what a picture costs is paid + in that order, so the file written, and the pictures the run's decoding allowance refuses, are + the same with any number. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5662a2bb6..34c8f9ca76 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -243,14 +243,22 @@ fn merge_page( images: &mut ImageBudget, ) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; + let resources = page_resources(doc, page).unwrap_or_default(); + let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + // Reading a heavy page takes seconds, so not one that has no picture that could be a fragment. + if !xobjects.iter().any(|(name, _)| fragment_image(doc, &xobjects, name).is_some()) { + return Ok(0); + } // A page whose content isn't all known is left alone: rewriting it would drop the rest. let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + // Nor one that doesn't draw enough things for a run, each fragment being a `Do`. + if data.windows(2).filter(|w| *w == b"Do").count() < MIN_FRAGMENTS { + return Ok(0); + } let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } - let resources = page_resources(doc, page).unwrap_or_default(); - let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); @@ -339,6 +347,9 @@ pub(crate) fn run( budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { for p in pages { + if !progress(Stage::Reading { done: 0, total: pages.len() }) { + return Err(OptimizeError::Cancelled); + } report.fragments_merged += merge_page(doc, *p, report, progress, budget)?; } Ok(()) @@ -405,6 +416,35 @@ mod tests { assert_eq!(budget.refused(), 1); } + /// A page drawing too few things for a run isn't parsed for one, even with a picture that could + /// be a fragment. + #[test] + fn too_few_draws_for_a_run_read_no_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let drawn = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(MIN_FRAGMENTS - 1); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX)); + assert_eq!(merged.ok(), Some(0)); + assert_eq!(GROUP_READS.with(|c| c.get()), 0); + } + /// Many images drawn on top of each other are not one picture; looking for runs among them reads /// a bounded number of groups per operator, not the rest of the page from every group. #[test] diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 264e1a2b9b..10388b8107 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -4,6 +4,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; +use std::sync::atomic::{AtomicU64, Ordering}; + use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; @@ -250,13 +252,20 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { /// decode within its budget or is damaged partway, every image is given `INFINITY` (never /// resampled), since its largest use may be among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - walk_pages(doc, pages).0 + let Ok((ppi, ..)) = walk_pages(doc, pages, &mut |_| true) else { return HashMap::new() }; + ppi } +/// What a walk of the pages found: each image's resolution, the images under remapped device +/// spaces, and the references followed. +type PagesWalked = (HashMap, HashSet, Read); + /// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is /// in force, by object number (all of them when a use may have been missed), and the references /// followed to reach them. -fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { +/// +/// `tick` hears how many pages are done, and stops the walk by returning false. +fn walk_pages(doc: &Document, pages: &[ObjRef], tick: &mut dyn FnMut(usize) -> bool) -> Result { let mut walk = Walk { doc, path: Vec::new(), @@ -269,7 +278,10 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSe defaults: HashSet::new(), read: Read::default(), }; - for p in pages { + for (done, p) in pages.iter().enumerate() { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } let Some(dict) = doc.get(*p).as_dict().cloned() else { continue }; let page = pdfcraft_model::Page { obj: *p, dict }; walk.resources_above(*p); @@ -288,7 +300,7 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSe walk.out.values_mut().for_each(|v| *v = f64::INFINITY); walk.defaults.extend(walk.out.keys().map(|r| r.num)); } - (walk.out, walk.defaults, walk.read) + Ok((walk.out, walk.defaults, walk.read)) } /// Whether image `r` is one [`one_bit`] may make grey: eight-bit `DeviceRGB`, not in an image codec. @@ -1330,7 +1342,7 @@ pub(crate) fn run( progress: &mut dyn FnMut(Stage) -> bool, budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { - let (ppi, mut defaults, read) = walk_pages(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages, &mut |done| progress(Stage::Reading { done, total: pages.len() }))?; // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an // indexed image's palette says which of its samples a resampled one keeps, which they may // change: one used where the walk didn't look may be drawn so. @@ -1347,93 +1359,73 @@ pub(crate) fn run( }) .collect(); let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); - refs.sort_by_key(|(r, _)| r.num); + refs.sort_by_key(|(r, _)| (r.num, r.generation)); let total = refs.len(); let mut jbig2 = false; let mut group: Vec = Vec::new(); - // Holds a JBIG2 image for `share`, which codes the group once it is full. - let mut hold = |doc: &mut Document, image: ObjRef, size: (Option, Option), bits: Option>, report: &mut Report| { - let side = |v: Option| v.and_then(|v| u32::try_from(v).ok()); - let (Some(bits), Some(width), Some(height)) = (bits, side(size.0), side(size.1)) else { return }; - hold_in(&mut group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + // By number: a repaired object answers to two generations. + let mut smask_uses: HashMap = HashMap::new(); + for (r, _) in &refs { + if let Some(m) = smask_of(doc, *r) { + *smask_uses.entry(m.num).or_default() += 1; + } + } + // Too few to be worth the threads, which keeps a small file's progress in step too. + let threads = if total < MIN_FOR_THREADS { + 1 + } else { + let largest = refs.iter().filter_map(|(r, _)| picture_pixels(doc, *r)).max().unwrap_or(0); + threads_for(crate::pool::workers(settings.workers), largest) }; - for (done, (r, ppi)) in refs.into_iter().enumerate() { - if !progress(Stage::Images { done, total }) { - return Err(OptimizeError::Cancelled); - } - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - report.images += 1; - let refused = budget.refused(); - // Packed from more bits a sample, it is lossless at one bit, but the grey or colour - // settings can make it smaller still (below). - let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); - let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) { - Some(m) if packed_from_more => (Some(m), None), - m => (None, m), - }; - if let Some((new, resampled, bits)) = mono { - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resampled { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; + let mut done = 0; + while let Some(rest) = refs.get(done..).filter(|rest| !rest.is_empty()) { + let run = if threads > 1 { batch_len(doc, rest, &smask_uses, settings.mono.compression == MonoCompression::Jbig2) } else { 0 }; + let Some(batch) = rest.get(..run).filter(|batch| batch.len() > 1) else { + if !progress(Stage::Images { done, total }) { + return Err(OptimizeError::Cancelled); } - jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); - let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); - let bits = bits.filter(|_| shares(&new.dict)); - doc.set(r, Object::Stream(new)); - hold(doc, r, size, bits, report); - continue; - } - let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); - let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); - // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft - // mask resampled with it. - let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { - Some((_, m)) => Some(m.raw.len()), - None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { - Object::Stream(m) => m.raw.len(), - _ => 0, - }), - }; - let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); - let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| { - processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) - }); - if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) { - jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resampled { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; + if let Some((r, ppi)) = rest.first() { + let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget); + apply(doc, decision, report, &mut jbig2, &mut group); } - let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); - let bits = bits.filter(|_| shares(&new.dict)); - doc.set(r, Object::Stream(new)); - hold(doc, r, size, bits, report); - continue; - } - let Some((new, smask)) = processed else { - // Read to be packed and again to be resampled, it is still one image, and it stayed. - report.images_refused += usize::from(budget.refused() > refused); + done += 1; continue; }; - let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resized { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; - } - doc.set(r, Object::Stream(new)); - if let Some((mr, m)) = smask { - doc.set(mr, Object::Stream(m)); + // Each thread works within what's left now, and pays for what it used in the document's + // order below: a picture the order can't pay for is done again as if there were no threads, + // so the same pictures are refused with any number of them. Once more than what's left has + // been used, no more are started. + let allowed = budget.work_left(); + let used = AtomicU64::new(0); + let tried = { + let (doc, defaults) = (&*doc, &defaults); + crate::pool::map( + batch, + threads, + |(r, ppi)| { + if used.load(Ordering::Relaxed) >= allowed { + return None; + } + let mut own = ImageBudget::with_work(allowed); + let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, &mut own); + let cost = allowed.saturating_sub(own.work_left()); + used.fetch_add(cost, Ordering::Relaxed); + (own.refused() == 0).then_some((decision, cost)) + }, + &mut |finished| progress(Stage::Images { done: done.saturating_add(finished), total }), + )? + }; + for ((r, ppi), tried) in batch.iter().zip(tried) { + let decision = match tried.flatten() { + Some((decision, cost)) if cost <= budget.work_left() => { + budget.charge(cost); + decision + } + _ => decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget), + }; + apply(doc, decision, report, &mut jbig2, &mut group); } + done += batch.len(); } share(doc, &mut group, report); if !progress(Stage::Images { done: total, total }) { @@ -1445,6 +1437,166 @@ pub(crate) fn run( Ok(()) } +/// A picture's soft mask, if it names one. +fn smask_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => s.dict.reference(b"SMask"), + _ => None, + } +} + +/// How many of `rest`, from the first, can be worked out beside each other. +fn batch_len(doc: &Document, rest: &[(ObjRef, f64)], smask_uses: &HashMap, holds_samples: bool) -> usize { + // One object under two generations: the second must see what the first left. Under one of them + // it can be a picture, and under the other another's soft mask. + let mut numbers = HashSet::new(); + let mut samples = 0usize; + rest.iter() + .take(PICTURES_AT_ONCE) + .take_while(|(r, _)| { + // Results wait for the end of the batch, samples kept to share a dictionary with them. + samples = samples.saturating_add(if holds_samples { held_samples(doc, *r) } else { 0 }); + // A soft mask two pictures share is changed by the first of them, and the second must + // see that, so they don't run beside others. + samples <= SHARED_BYTES + && !smask_uses.contains_key(&r.num) + && numbers.insert(r.num) + && smask_of(doc, *r).is_none_or(|m| smask_uses.get(&m.num) == Some(&1)) + }) + .count() +} + +/// The most bytes of 1-bit samples picture `r` could keep to share a dictionary: whole bytes a row. +fn held_samples(doc: &Document, r: ObjRef) -> usize { + let object = doc.get(r); + let Object::Stream(s) = &*object else { return 0 }; + let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()); + let Some((w, h)) = side(b"Width").zip(side(b"Height")) else { return 0 }; + if w.saturating_mul(h) > SHARED_PIXELS { + return 0; + } + usize::try_from(w.div_ceil(8).saturating_mul(h)).unwrap_or(usize::MAX) +} + +/// Below this many pictures, one thread does them. +const MIN_FOR_THREADS: usize = 16; + +/// How many pictures run beside each other before what they used is paid for. +const PICTURES_AT_ONCE: usize = 32; + +/// What the pictures being worked out may hold in memory together. +const PICTURE_MEMORY: u64 = 1 << 30; + +/// What a picture holds while it is worked out, for each pixel: decoded, and resampled or encoded. +const PICTURE_BYTES_PER_PIXEL: u64 = 8; + +/// A picture's pixels, if it is small enough to be decoded at all. +fn picture_pixels(doc: &Document, r: ObjRef) -> Option { + let object = doc.get(r); + let Object::Stream(s) = &*object else { return None }; + let (w, h) = (u64::try_from(s.dict.int(b"Width")?).ok()?, u64::try_from(s.dict.int(b"Height")?).ok()?); + w.checked_mul(h).filter(|pixels| *pixels <= MAX_PIXELS) +} + +/// How many pictures of `largest` pixels to work on at once: `workers`, or as many as +/// [`PICTURE_MEMORY`] holds, and at least one. +fn threads_for(workers: usize, largest: u64) -> usize { + let fit = PICTURE_MEMORY / largest.saturating_mul(PICTURE_BYTES_PER_PIXEL).max(1); + workers.min(usize::try_from(fit).unwrap_or(usize::MAX)).max(1) +} + +/// What's to become of one picture. +enum Decision { + /// Not an image stream after all. + NotAnImage, + /// An image that stays as it is, `refused` if the allowance couldn't pay for it. + Stays { refused: bool }, + Replace { + image: ObjRef, + before: usize, + new: Stream, + resampled: bool, + smask: Option<(ObjRef, Stream)>, + /// Its 1-bit samples, if it may share a JBIG2 dictionary ([`share`]). + bits: Option>, + }, +} + +/// Do what was decided for one picture, and count it. +fn apply(doc: &mut Document, decision: Decision, report: &mut Report, jbig2: &mut bool, group: &mut Vec) { + let (image, before, new, resampled, smask, bits) = match decision { + Decision::NotAnImage => return, + Decision::Stays { refused } => { + report.images += 1; + report.images_refused += usize::from(refused); + return; + } + Decision::Replace { image, before, new, resampled, smask, bits } => (image, before, new, resampled, smask, bits), + }; + report.images += 1; + *jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + report.image_bytes_before += before; + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + let side = |key: &[u8]| new.dict.int(key).and_then(|v| u32::try_from(v).ok()); + let held = bits.zip(side(b"Width")).zip(side(b"Height")); + doc.set(image, Object::Stream(new)); + if let Some((mr, m)) = smask { + doc.set(mr, Object::Stream(m)); + } + // A JBIG2 image waits for `share`, which codes the group once it is full. + if let Some(((bits, width), height)) = held { + hold_in(group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + } +} + +/// Work out what's to become of picture `r`, changing nothing yet. +fn decide(doc: &Document, r: ObjRef, ppi: f64, defaults: bool, settings: &Settings, budget: &mut ImageBudget) -> Decision { + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { return Decision::NotAnImage }; + let refused = budget.refused(); + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults, &settings.mono, budget) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled, bits)) = mono { + let bits = bits.filter(|_| shares(&new.dict)); + return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits }; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) { + let bits = bits.filter(|_| shares(&new.dict)); + return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits }; + } + let Some((new, smask)) = processed else { + // Read to be packed and again to be resampled, it is still one image, and it stayed. + return Decision::Stays { refused: budget.refused() > refused }; + }; + let resampled = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); + Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask, bits: None } +} + /// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 /// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 /// §7.7.2). A full save with object streams writes a 1.5 header anyway. @@ -1492,6 +1644,37 @@ mod dimension_tests { assert_eq!((count, group.len()), (SHARED_IMAGES, 0), "coded on the 16th"); } + #[test] + fn a_batch_holds_no_more_samples_than_a_group_may() { + let mut doc = Document::new_empty(); + let mut add = |width: i64, height: i64| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + (doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))), 72.0) + }; + // Eight megabytes of samples each, and one byte a pixel when one pixel wide. + let large: Vec<_> = (0..12).map(|_| add(8192, 8192)).collect(); + let thin: Vec<_> = (0..8).map(|_| add(1, 32 << 20)).collect(); + let small: Vec<_> = (0..40).map(|_| add(100, 100)).collect(); + // Past what may share a dictionary, so never held. + let huge: Vec<_> = (0..12).map(|_| add(16384, 16384)).collect(); + let none = HashMap::new(); + assert_eq!(batch_len(&doc, &large, &none, true), 8); + assert_eq!(batch_len(&doc, &thin, &none, true), 2); + assert_eq!(batch_len(&doc, &small, &none, true), PICTURES_AT_ONCE); + assert_eq!(batch_len(&doc, &huge, &none, true), huge.len()); + assert_eq!(batch_len(&doc, &large, &none, false), large.len(), "nothing is held but for JBIG2"); + } + + #[test] + fn threads_are_limited_by_the_memory_of_the_largest_picture() { + assert_eq!(threads_for(8, 1_000_000), 8); + assert_eq!(threads_for(8, 40_000_000), 3); + assert_eq!(threads_for(8, MAX_PIXELS), 1); + assert_eq!(threads_for(8, 0), 8); + } + /// Only what redaction can store again without the dictionary shares one: an image mask, or /// DeviceGray named directly, of at most 2^26 pixels. #[test] @@ -1609,6 +1792,63 @@ mod dimension_tests { assert_eq!(changed, 2, "the third is as it was"); } + /// An allowance that runs out refuses the same pictures with any number of threads as it does + /// one after another. + #[test] + fn the_allowance_refuses_the_same_pictures_with_any_number_of_threads() { + let picture = |shade: u32| { + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(400)); + dict.set(b"Height".to_vec(), Object::Int(400)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = (0..400u32 * 400).map(|i| (i.wrapping_mul(shade + 3) % 251) as u8).collect(); + Stream::flate(dict, &samples) + }; + let build = || { + let mut doc = Document::new_empty(); + // A few of different sizes, so what's left when the allowance ends depends on the order. + let refs: Vec = (0..40u32).map(|i| doc.add(Object::Stream(picture(i)))).collect(); + let mut xobjects = Dict::new(); + let mut content = String::from("q 72 0 0 72 0 0 cm "); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + content.push_str(&format!("/I{i} Do ")); + } + content.push('Q'); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + (doc, refs, page) + }; + let run_with = |work: u64, workers: usize| { + let (mut doc, refs, page) = build(); + let settings = Settings { workers: Some(workers), ..Settings::default() }; + let (mut report, mut budget) = (Report::default(), ImageBudget::with_work(work)); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + let raws: Vec> = refs.iter().map(|r| if let Object::Stream(s) = &*doc.get(*r) { s.raw.to_vec() } else { Vec::new() }).collect(); + (format!("{report:?}"), budget.work_left(), raws) + }; + let all = u64::MAX - run_with(u64::MAX, 1).1; + // Past one batch of what threads run together, and part of the way through a picture. + let work = all / 40 * 37 + all / 80; + let one = run_with(work, 1); + assert!(one.0.contains("images_refused: 3"), "{}", one.0); + for workers in [2, 4, 8] { + let many = run_with(work, workers); + assert_eq!(many.0, one.0, "{workers} threads count the same"); + assert_eq!(many.1, one.1, "{workers} threads leave the same allowance"); + assert!(many.2 == one.2, "{workers} threads store the same pictures"); + } + } + /// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled /// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run /// as long as one of JBIG2, and those past it stay as they are. diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index ef6423ab7d..275c6f32a6 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -32,6 +32,7 @@ mod audit; pub mod fragments; mod images; mod links; +mod pool; mod samples; mod unused; @@ -41,6 +42,7 @@ use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; +pub use pool::give_way_to_other_programs; pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; #[derive(Debug, thiserror::Error)] @@ -106,6 +108,8 @@ impl Compatibility { /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { + /// Reading what pages draw: `done` of `total` read, none yet while looking for fragments. + Reading { done: usize, total: usize }, /// About to process image `done` (0-based) of `total`; `done == total` once all are done. Images { done: usize, total: usize }, /// Merging the fragments of a page picture into one image (only when a page has some). @@ -181,6 +185,8 @@ impl ImageSettings { pub struct Settings { /// Target PDF version (`Retain` by default). pub compatibility: Compatibility, + /// Threads for pictures: `None` picks from the machine. The result is the same with any number. + pub workers: Option, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, @@ -217,6 +223,7 @@ impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + workers: None, compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, diff --git a/crates/optimize/src/pool.rs b/crates/optimize/src/pool.rs new file mode 100644 index 0000000000..57dcc7fdb1 --- /dev/null +++ b/crates/optimize/src/pool.rs @@ -0,0 +1,262 @@ +//! Work over many pages or images on several threads, the results in order, so that what is +//! written is the same however many threads did it. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use crate::OptimizeError; + +/// Threads beyond this add more memory than speed: each holds a page or an image being worked on. +const MAX_WORKERS: usize = 8; +/// How long Cancel can wait while every thread is busy with something long. +const CANCEL_CHECK: Duration = Duration::from_millis(100); + +/// Let other programs go first on this thread: the wait for a copy matters less than whatever else +/// is open. A system that refuses leaves it as it was. +pub fn give_way_to_other_programs() { + #[cfg(not(target_arch = "wasm32"))] + let _ = thread_priority::set_current_thread_priority(thread_priority::ThreadPriority::Min); +} + +/// How many threads to use: `asked`, or half the machine's processors up to [`MAX_WORKERS`], so a +/// bigger machine goes faster and every machine keeps room for other programs. One on the web, which +/// has no threads here. +pub(crate) fn workers(asked: Option) -> usize { + if cfg!(target_arch = "wasm32") { + return 1; + } + asked.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).clamp(1, MAX_WORKERS))).max(1) +} + +/// `work` on each of `items`, results in the order of `items`. `tick` hears how many are done, and +/// stops the run by returning false. With more than one worker an item whose work panics gets +/// `None`, and the caller decides what becomes of it; with one, the panic goes on up as it does for +/// any other work. +pub(crate) fn map( + items: &[T], + workers: usize, + work: impl Fn(&T) -> R + Sync, + tick: &mut dyn FnMut(usize) -> bool, +) -> Result>, OptimizeError> { + let mut out: Vec> = Vec::with_capacity(items.len()); + if workers <= 1 || items.len() <= 1 { + for (done, item) in items.iter().enumerate() { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } + out.push(Some(work(item))); + } + return Ok(out); + } + out.resize_with(items.len(), || None); + let (next, stop) = (AtomicUsize::new(0), AtomicBool::new(false)); + let mut stopped = false; + std::thread::scope(|scope| { + let (results, finished) = channel(); + for _ in 0..workers.min(items.len()) { + let results = results.clone(); + let (next, stop, work) = (&next, &stop, &work); + // A thread that can't start only leaves more for the others, or for below. + let _ = std::thread::Builder::new().name("pdfcraft-optimize-worker".into()).spawn_scoped(scope, move || { + give_way_to_other_programs(); + while !stop.load(Ordering::Relaxed) { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + let result = catch_unwind(AssertUnwindSafe(|| work(item))).ok(); + if results.send((i, result)).is_err() { + break; + } + } + }); + } + drop(results); + let mut done = 0; + loop { + if !stopped && !tick(done) { + stopped = true; + stop.store(true, Ordering::Relaxed); + } + match finished.recv_timeout(CANCEL_CHECK) { + Ok((i, result)) => { + if let Some(slot) = out.get_mut(i) { + *slot = result; + } + done += 1; + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + }); + if stopped { + return Err(OptimizeError::Cancelled); + } + // No thread could start, or the last of them stopped early: the rest are done here. + let claimed = next.load(Ordering::Relaxed).min(items.len()); + for (done, (item, slot)) in items.iter().zip(out.iter_mut()).enumerate().skip(claimed) { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } + *slot = Some(work(item)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + #[test] + fn results_keep_their_order_whatever_finishes_first() { + let items: Vec = (0..200).collect(); + let out = map( + &items, + 4, + |i| { + // The early ones finish last. + std::thread::sleep(Duration::from_micros(200u64.saturating_sub(*i))); + i * 2 + }, + &mut |_| true, + ) + .unwrap(); + assert_eq!(out, items.iter().map(|i| Some(i * 2)).collect::>()); + } + + #[test] + fn one_worker_does_them_in_order_on_this_thread() { + let here = std::thread::current().id(); + let order = Mutex::new(Vec::new()); + let mut ticks = Vec::new(); + map( + &[1, 2, 3], + 1, + |i| { + assert_eq!(std::thread::current().id(), here); + order.lock().unwrap().push(*i); + }, + &mut |done| { + ticks.push(done); + true + }, + ) + .unwrap(); + assert_eq!(*order.lock().unwrap(), [1, 2, 3]); + assert_eq!(ticks, [0, 1, 2]); + } + + #[test] + fn several_workers_share_the_work() { + let threads = Mutex::new(std::collections::HashSet::new()); + map( + &[0; 64], + 4, + |_| { + threads.lock().unwrap().insert(std::thread::current().id()); + std::thread::sleep(Duration::from_millis(5)); + }, + &mut |_| true, + ) + .unwrap(); + assert!(threads.lock().unwrap().len() > 1, "it really ran on more than one"); + } + + #[test] + fn stopping_stops_and_says_so() { + let ran = AtomicUsize::new(0); + let r = map( + &[0; 500], + 4, + |_| { + ran.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(1)); + }, + &mut |done| done < 8, + ); + assert!(matches!(r, Err(OptimizeError::Cancelled))); + assert!(ran.load(Ordering::Relaxed) < 500, "the rest never started"); + } + + /// Cancel is heard while every thread is busy with something long, not only between items. + #[test] + fn a_stop_gets_through_while_every_thread_is_busy() { + let mut asked_while_busy = 0; + map(&[0; 2], 2, |_| std::thread::sleep(Duration::from_millis(600)), &mut |done| { + if done == 0 { + asked_while_busy += 1; + } + true + }) + .unwrap(); + assert!(asked_while_busy >= 3, "asked {asked_while_busy} times before anything finished"); + + let started = std::time::Instant::now(); + let mut asked = 0; + let r = map(&[0; 4], 2, |_| std::thread::sleep(Duration::from_millis(400)), &mut |_| { + asked += 1; + asked < 3 + }); + assert!(matches!(r, Err(OptimizeError::Cancelled))); + assert!(started.elapsed() < Duration::from_millis(1500), "the items not started never ran"); + } + + #[test] + fn an_item_that_panics_is_left_alone_and_the_rest_are_done() { + let items: Vec = (0..32).collect(); + let out = map( + &items, + 4, + |i| { + assert_ne!(*i, 7, "this one panics"); + *i + }, + &mut |_| true, + ) + .unwrap(); + for (i, got) in out.iter().enumerate() { + assert_eq!(*got, (i != 7).then_some(i as u32), "item {i}"); + } + } + + /// The workers give way, and the thread that asked keeps its own priority. + #[cfg(windows)] + #[test] + fn workers_give_way_to_other_programs() { + use thread_priority::get_current_thread_priority; + let here = get_current_thread_priority().unwrap(); + let theirs = Mutex::new(Vec::new()); + map( + &[0; 8], + 2, + |_| { + theirs.lock().unwrap().push(get_current_thread_priority().unwrap()); + std::thread::sleep(Duration::from_millis(2)); + }, + &mut |_| true, + ) + .unwrap(); + assert_eq!(get_current_thread_priority().unwrap(), here); + let theirs = theirs.into_inner().unwrap(); + assert_eq!(theirs.len(), 8); + assert!(theirs.iter().all(|p| *p != here), "{theirs:?} against {here:?}"); + } + + #[test] + fn nothing_to_do_is_fine() { + let none: [u8; 0] = []; + assert!(map(&none, 4, |_| 1, &mut |_| true).unwrap().is_empty()); + assert_eq!(map(&[5], 4, |i| *i, &mut |_| true).unwrap(), [Some(5)]); + } + + #[test] + fn the_web_and_small_machines_get_a_sensible_count() { + assert_eq!(workers(Some(3)), if cfg!(target_arch = "wasm32") { 1 } else { 3 }); + assert_eq!(workers(Some(0)), 1, "never none"); + let picked = workers(None); + assert!((1..=MAX_WORKERS).contains(&picked), "{picked}"); + } +} diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs index c4dfabc9d9..4e08185d77 100644 --- a/crates/optimize/tests/jpeg_color_transform.rs +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -395,9 +395,9 @@ fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() { #[test] fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { // One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case - // reported on #3012). Fragment merging reads its header for each group it tries, before the - // first progress call. The header scan stops at the first stray byte, so that call is the - // first image, and cancelling there stops Optimize. + // reported on #3012). Fragment merging reads its header for each group it tries. The header + // scan stops at the first stray byte, so the first image comes soon, and cancelling there stops + // Optimize. Cancelling while pages are read stops it sooner still. let (w, h) = (128, 8); let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]); let mut doc = Document::new_empty(); @@ -411,8 +411,16 @@ fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { let mut stages = Vec::new(); let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| { stages.push(stage); + !matches!(stage, Stage::Images { .. }) + }); + assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); + assert_eq!(stages.last(), Some(&Stage::Images { done: 0, total: 1 })); + assert_eq!(stages.first(), Some(&Stage::Reading { done: 0, total: 1 }), "it can be stopped from the first call"); + let mut stages = 0; + let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |_| { + stages += 1; false }); assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); - assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]); + assert_eq!(stages, 1); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2785ddac6c..c4fb865198 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2647,7 +2647,9 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { }; let mut seen = Vec::new(); let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| { - seen.push(s); + if !matches!(s, Stage::Reading { .. }) { + seen.push(s); + } true }) .unwrap(); @@ -2665,7 +2667,10 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { // Stopping after the first image. let mut calls = 0; - let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| { + let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| { + if matches!(s, Stage::Reading { .. }) { + return true; + } calls += 1; calls < 2 }); @@ -4289,3 +4294,135 @@ fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() { assert_eq!(stream(&doc, img), before, "{what}"); } } + +/// Pictures run on several threads: what comes out doesn't depend on how many. +#[test] +fn the_same_file_comes_out_with_any_number_of_threads() { + let build = || { + let mut doc = Document::new_empty(); + let shared_mask = image(&mut doc, 1200, 1200, 1, false, None); + // Under a soft mask they share, at different sizes on the page: the second must start from + // the mask the first left. Each needs the mask at a different size. + let first = image(&mut doc, 1200, 1200, 3, true, Some(shared_mask)); + let second = image(&mut doc, 1000, 1000, 3, true, Some(shared_mask)); + page(&mut doc, &[("Im0", first)], "q 144 0 0 144 100 100 cm /Im0 Do Q"); + page(&mut doc, &[("Im0", second)], "q 180 0 0 180 100 100 cm /Im0 Do Q"); + // Enough others for the threads. + for page_no in 0..22u32 { + let img = image(&mut doc, 500 + page_no, 500, 3, true, None); + page(&mut doc, &[("Im0", img)], "q 72 0 0 72 100 100 cm /Im0 Do Q"); + } + doc + }; + let run = |workers| { + let mut doc = build(); + let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap(); + assert_eq!(report.images, 24, "{report:?}"); + assert!(report.images_resampled + report.images_recompressed > 16, "{report:?}"); + (write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}")) + }; + let (one, one_report) = run(1); + for workers in [2, 4, 7] { + let (many, many_report) = run(workers); + assert_eq!(many_report, one_report, "{workers} threads count the same"); + assert!(many == one, "{workers} threads write the same bytes"); + } +} + +/// A table that disagrees with an object's header leaves it reachable under two generations, as +/// one object: each use decides from what the one before it left, with any number of threads. +#[test] +fn one_object_under_two_generations_comes_out_the_same_with_any_number_of_threads() { + let picture = |size: usize, extra: &str| { + let samples: Vec = (0..size * size).map(|i| ((i % size) * 255 / size) as u8 ^ ((i / size) as u8 & 7)).collect(); + let dict = format!( + "<< /Type /XObject /Subtype /Image /Width {size} /Height {size} /BitsPerComponent 8 /ColorSpace /DeviceGray{extra} /Length {} >>\nstream\n", + samples.len() + ); + [dict.as_bytes(), &samples, b"\nendstream"].concat() + }; + let build = || { + // 1 the catalog, 2 the pages, 3 the mask, 4 and 5 under it, 6 the picture, 7 a picture that is + // also 8's mask, then the others. + let mut objects = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), Vec::new()]; + objects.push(picture(1200, "")); + objects.push(picture(1200, " /SMask 3 0 R")); + objects.push(picture(1000, " /SMask 3 1 R")); + objects.push(picture(1200, "")); + objects.push(picture(1200, "")); + objects.push(picture(1000, " /SMask 7 1 R")); + // Small under one generation, large under the other: they want different sizes. + let mut draws = vec![ + ("4 0 R".to_string(), 144), + ("5 0 R".to_string(), 180), + ("6 0 R".to_string(), 72), + ("6 1 R".to_string(), 576), + ("7 0 R".to_string(), 72), + ("8 0 R".to_string(), 180), + ]; + for i in 0..22 { + objects.push(picture(500 + i, "")); + draws.push((format!("{} 0 R", objects.len()), 72)); + } + let mut kids = String::new(); + for (drawn, size) in &draws { + let content = format!("q {size} 0 0 {size} 10 10 cm /Im0 Do Q"); + objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes()); + let contents = objects.len(); + objects.push( + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {contents} 0 R /Resources << /XObject << /Im0 {drawn} >> >> >>" + ) + .into_bytes(), + ); + kids.push_str(&format!("{} 0 R ", objects.len())); + } + objects[1] = format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", draws.len()).into_bytes(); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1); + for (i, object) in objects.iter().enumerate() { + let num = i + 1; + // Headers say generation 0, and the table says 1 of these three. + let generation = u8::from(num == 3 || num == 6 || num == 7); + table.push_str(&format!("{:010} {generation:05} n \n", bytes.len())); + bytes.extend(format!("{num} 0 obj\n").as_bytes()); + bytes.extend(object); + bytes.extend(b"\nendobj\n"); + } + let start = bytes.len(); + bytes.extend(table.as_bytes()); + bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objects.len() + 1).as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + for (num, generation) in [(3, 0), (3, 1), (6, 0), (6, 1), (7, 0), (7, 1)] { + assert!(matches!(&*doc.get(ObjRef { num, generation }), Object::Stream(_)), "{num} {generation} finds the object"); + } + doc + }; + let run = |workers| { + let mut doc = build(); + let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap(); + (write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}")) + }; + let (one, one_report) = run(1); + for workers in [2, 4, 7] { + let (many, many_report) = run(workers); + assert_eq!(many_report, one_report, "{workers} threads count the same"); + assert!(many == one, "{workers} threads write the same bytes"); + } +} + +/// Pages too small for a run aren't read, and that must not cost the smallest run there can be. +#[test] +fn the_fewest_strips_that_make_a_picture_are_merged() { + use pdfcraft_optimize::fragments::MIN_FRAGMENTS; + let keep = Settings { + gray: ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }, + ..Settings::default() + }; + let (mut doc, _) = printed_page(MIN_FRAGMENTS as _, |_| false, |_, s| s); + let r = optimize(&mut doc, &keep).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (1, MIN_FRAGMENTS), "{r:?}"); + let (mut doc, _) = printed_page((MIN_FRAGMENTS - 1) as _, |_| false, |_, s| s); + let r = optimize(&mut doc, &keep).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}"); +} diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 6c6bedad00..89f82b00a0 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -80,6 +80,11 @@ pub struct OptimizeRun { fn stage_label(stage: OptimizeStage) -> String { match stage { OptimizeStage::Discarding => tl!("Optimizing… removing user data").to_string(), + OptimizeStage::Reading { done: 0, .. } => tl!("Optimizing… reading the pages").to_string(), + OptimizeStage::Reading { done, total } => crate::i18n::fmt( + tl!("Optimizing… reading page {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ), OptimizeStage::Fragments => tl!("Optimizing… merging image fragments").to_string(), OptimizeStage::Images { done, total } => crate::i18n::fmt( tl!("Optimizing… image {d} of {t}"), @@ -418,7 +423,10 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] if self.run_inline { work(); - } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-optimize".into()).spawn(work) { + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-optimize".into()).spawn(move || { + pdfcraft_engine::optimize::give_way_to_other_programs(); + work(); + }) { // No worker: report the failure instead of waiting for a result that never comes. if let Ok(mut s) = progress.lock() { s.result = Some(Err(EditError::Optimize(e.to_string()))); @@ -504,7 +512,7 @@ mod tests { } h.get_by_label("Optimizing… image 4 of 10"); let shown = h.state().progress_notice.clone().unwrap(); - assert!((shown.fraction - 0.275).abs() < 1e-4, "{shown:?}"); + assert!((shown.fraction - 0.415).abs() < 1e-4, "{shown:?}"); assert!(shown.cancellable); h.get_by_label("Cancel").click(); @@ -520,6 +528,16 @@ mod tests { assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("Optimization cancelled")); } + #[test] + fn the_progress_card_says_what_is_happening() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + assert_eq!(stage_label(OptimizeStage::default()), "Optimizing… reading the pages"); + assert_eq!(stage_label(OptimizeStage::Reading { done: 11, total: 51 }), "Optimizing… reading page 12 of 51"); + assert_eq!(stage_label(OptimizeStage::Reading { done: 51, total: 51 }), "Optimizing… reading page 51 of 51"); + assert_eq!(stage_label(OptimizeStage::Discarding), "Optimizing… removing user data"); + } + #[test] fn a_second_run_is_refused_while_one_is_going() { let mut app = PdfCraftApp::new(); From c9b5ee185be422e0a22b3e8cf42675303b371059 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:53:48 +0800 Subject: [PATCH 14/29] M6: Shift keeps a field's move horizontal or vertical in Prepare a form, as in Acrobat (form.prepare-form-mode) (#6260) --- crates/ui-egui/src/tools/prepare.rs | 88 ++++++++++++++++++++++++++--- crates/ui-egui/tests/it/prepare.rs | 42 ++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 405ce5ac84..3622c1fe18 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -140,7 +140,8 @@ enum Grab { Draw(Pos2), /// Selecting fields inside a dragged rectangle. Select(Pos2), - Move, + /// Moving the selected field; with Shift held, the axis the move keeps to once [`lock_axis`] chose it. + Move(Option), /// Resizing by a corner: (moves left edge, moves top edge) in screen terms. Corner(bool, bool), } @@ -229,11 +230,47 @@ fn handles(r: Rect) -> [(Pos2, bool, bool); 4] { [(r.left_top(), true, true), (r.right_top(), false, true), (r.left_bottom(), true, false), (r.right_bottom(), false, false)] } -/// The rect while a move or resize is in progress. With `shift` held a corner keeps the field's -/// proportions, as it does for comments and stamps. +/// The direction a Shift-constrained move keeps to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Axis { + Horizontal, + Vertical, +} + +impl Axis { + /// The axis `delta` has gone further along; horizontal on a tie. + fn of(delta: egui::Vec2) -> Self { + if delta.x.abs() >= delta.y.abs() { Axis::Horizontal } else { Axis::Vertical } + } +} + +/// How far, in screen pixels, a Shift-move goes before its axis is chosen, so the first pixel of +/// hand jitter does not pick it. +const AXIS_LOCK: f32 = 4.0; + +/// The axis a move keeps to with `shift` held: chosen once the drag has gone [`AXIS_LOCK`] pixels, +/// from the direction it has gone furthest, and then kept while Shift stays down, so a drag that +/// turns and crosses the diagonal does not flip it and make the field jump. Releasing Shift frees +/// the move, and pressing it again chooses afresh. +fn lock_axis(axis: Option, delta: egui::Vec2, shift: bool) -> Option { + if !shift { + return None; + } + axis.or_else(|| (delta.length() >= AXIS_LOCK).then(|| Axis::of(delta))) +} + +/// The rect while a move or resize is in progress. With `shift` held a move keeps to one axis, +/// horizontal or vertical, as Acrobat's Prepare Form does ("To constrain the vertical or horizontal +/// movement as you drag, press and hold Shift"): the one [`lock_axis`] chose, or until it has +/// chosen, the one the drag has gone furthest along. A corner keeps the field's proportions, as it +/// does for comments and stamps. fn dragged(r: Rect, grab: Grab, delta: egui::Vec2, shift: bool) -> Rect { match grab { - Grab::Move => r.translate(delta), + Grab::Move(axis) if shift => r.translate(match axis.unwrap_or_else(|| Axis::of(delta)) { + Axis::Horizontal => vec2(delta.x, 0.0), + Axis::Vertical => vec2(0.0, delta.y), + }), + Grab::Move(_) => r.translate(delta), Grab::Corner(left, top) => { let handle = (if left { -1 } else { 1 }, if top { -1 } else { 1 }); comments::resized(r, handle, delta, comments::corner_ratio(r, None, shift)) @@ -315,10 +352,14 @@ pub(crate) fn page_input( } // A grab in progress on this page. - if let Some((gp, grab, start)) = prep.grab + if let Some((gp, mut grab, start)) = prep.grab && gp == page { out.consumed = true; + if let (Grab::Move(axis), Some(p)) = (grab, pointer) { + grab = Grab::Move(lock_axis(axis, p - start, ui.input(|i| i.modifiers.shift))); + prep.grab = Some((gp, grab, start)); + } if resp.drag_stopped() || !ptr.primary_down { prep.grab = None; let end = pointer.unwrap_or(start); @@ -361,7 +402,7 @@ pub(crate) fn page_input( view.pending_edit = Some(Edit::AddField { page, rect, kind: tool.new_field(join), name: None }); out.placed = true; } - Grab::Move | Grab::Corner(..) => { + Grab::Move(_) | Grab::Corner(..) => { let Some((name, wi)) = prep.selected.clone() else { return out }; let Some(w) = form.iter().find(|f| f.name == name).and_then(|f| f.widgets.get(wi)) else { return out }; let delta = end - start; @@ -402,7 +443,7 @@ pub(crate) fn page_input( prep.grab = Some((page, Grab::Corner(left, top), origin)); } else if let Some((f, wi)) = hit { prep.selected = Some((f.name.clone(), wi)); - prep.grab = Some((page, Grab::Move, origin)); + prep.grab = Some((page, Grab::Move(None), origin)); } else { prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect()); if !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -778,6 +819,37 @@ mod tests { assert_eq!(if left { out.max.x } else { out.min.x }, if left { r.max.x } else { r.min.x }); assert_eq!(if top { out.max.y } else { out.min.y }, if top { r.max.y } else { r.min.y }); } - assert_eq!(dragged(r, Grab::Move, vec2(5.0, 6.0), true), r.translate(vec2(5.0, 6.0))); + assert_eq!(dragged(r, Grab::Move(None), vec2(5.0, 6.0), false), r.translate(vec2(5.0, 6.0))); + } + + /// Shift keeps a move to one axis: whichever the drag has gone further along, so the field + /// follows the pointer's main direction and never drifts on the other. + #[test] + fn shift_keeps_a_move_horizontal_or_vertical() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(196.0, 132.0)); + assert_eq!(dragged(r, Grab::Move(None), vec2(40.0, 6.0), true), r.translate(vec2(40.0, 0.0)), "mostly right"); + assert_eq!(dragged(r, Grab::Move(None), vec2(-40.0, 6.0), true), r.translate(vec2(-40.0, 0.0)), "mostly left"); + assert_eq!(dragged(r, Grab::Move(None), vec2(5.0, -30.0), true), r.translate(vec2(0.0, -30.0)), "mostly up"); + assert_eq!(dragged(r, Grab::Move(None), vec2(-5.0, 30.0), true), r.translate(vec2(0.0, 30.0)), "mostly down"); + // Releasing Shift moves freely again. + assert_eq!(dragged(r, Grab::Move(None), vec2(40.0, 6.0), false), r.translate(vec2(40.0, 6.0))); + // A chosen axis holds even where the drag has gone further along the other one. + assert_eq!(dragged(r, Grab::Move(Some(Axis::Horizontal)), vec2(40.0, 60.0), true), r.translate(vec2(40.0, 0.0))); + assert_eq!(dragged(r, Grab::Move(Some(Axis::Vertical)), vec2(60.0, 40.0), true), r.translate(vec2(0.0, 40.0))); + assert_eq!(dragged(r, Grab::Move(Some(Axis::Vertical)), vec2(60.0, 40.0), false), r.translate(vec2(60.0, 40.0))); + } + + /// The axis is chosen once the drag has gone a few pixels with Shift held, kept while Shift + /// stays down whichever way the drag then turns, and freed when Shift is let go. + #[test] + fn a_shift_move_locks_its_axis_until_shift_is_let_go() { + assert_eq!(lock_axis(None, vec2(2.0, 1.0), true), None, "not yet past the jitter"); + let axis = lock_axis(None, vec2(40.0, 0.0), true); + assert_eq!(axis, Some(Axis::Horizontal)); + assert_eq!(lock_axis(axis, vec2(40.0, 42.0), true), axis, "crossing the diagonal keeps it"); + assert_eq!(lock_axis(axis, vec2(0.0, 300.0), true), axis); + assert_eq!(lock_axis(axis, vec2(40.0, 42.0), false), None, "Shift let go frees it"); + assert_eq!(lock_axis(None, vec2(40.0, 42.0), true), Some(Axis::Vertical), "and pressing it again chooses afresh"); + assert_eq!(lock_axis(None, vec2(f32::NAN, 0.0), true), None, "a non-finite delta chooses nothing"); } } diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 749eaac69a..457bfce8c9 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -156,6 +156,48 @@ fn arrow_keys_nudge_the_selected_field_instead_of_turning_the_page() { assert_eq!(h.state().views[0].current, 0, "the arrows moved the field, not the page"); } +/// Shift keeps a dragged field on the axis it set off along, even when the drag then turns and +/// crosses the diagonal: 40 points right and then 60 down still moves it 40 points right and not +/// at all down, where choosing the axis from the whole drag each frame made it jump to 60 down. +#[test] +fn a_shift_drag_keeps_its_axis_when_it_crosses_the_diagonal() { + let mut h = harness(); + h.state_mut().execute("form.add.text"); + h.run_steps(1); + let p = at(&h, 40.0, 250.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); + let name = h.state().views[0].prepare.selected.clone().expect("the placed field is selected").0; + let before = rect_of(&h, &name); + // The default text field is 144 by 22 points with its top-left at the click. + let c = at(&h, 110.0, 261.0); + let (right, down) = (at(&h, 150.0, 261.0), at(&h, 150.0, 321.0)); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(c + (right - c) * (k as f32 / 4.0)); + h.run_steps(1); + } + for k in 1..=4 { + h.hover_at(right + (down - right) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(down); + h.run_steps(1); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); + let after = rect_of(&h, &name); + assert!((after[0] - before[0] - 40.0).abs() < 1.5, "40 points right: {before:?} → {after:?}"); + assert!((after[1] - before[1]).abs() < 0.01, "not down: {before:?} → {after:?}"); +} + /// #3632: Alt+← / Alt+→ go back and forward through the view history with a field selected, and /// leave the field where it is, even when Alt is let go in the same frame as the arrow, so the /// frame has no modifiers and only the press carries Alt. From 58c30af312f193c1d73ff8e2ea55904e078534cd Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:54:03 +0800 Subject: [PATCH 15/29] fix(render): a JBIG2 scan whose data starts with a JBIG2 file header draws instead of a white page (hayro-jbig2 patch 17) (#6279) --- crates/filters/src/jbig2/decode.rs | 119 +++++++++++++++++++++++++++++ crates/render/src/raster/tests.rs | 17 +++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/file.rs | 21 ++++- vendor/hayro-jbig2/src/lib.rs | 23 +++++- 5 files changed, 174 insertions(+), 8 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index ddb70270d8..26ed16b198 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -110,6 +110,125 @@ mod tests { data } + /// The JBIG2 file header's ID string (ISO 14492 D.4.1). + const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; + + /// `embedded`, segments as [`encode_jbig2_generic`] writes them (11-byte headers: no references, + /// a one-byte page association), behind a file header with `flags` and the page count `pages`, + /// in the organization `flags` declares (D.4.2, bit 0): 1 sequential, each segment's header + /// before its data (D.1); 0 random access, every header first, the last an end of file segment + /// numbered 2, which tells where they end, then every segment's data in the same order (D.2). + fn with_file_header(embedded: &[u8], flags: u8, pages: Option) -> Vec { + let mut data = FILE_HEADER_ID.to_vec(); + data.push(flags); + data.extend(pages.map(u32::to_be_bytes).into_iter().flatten()); + if flags & 0x01 != 0 { + data.extend_from_slice(embedded); + return data; + } + let (mut headers, mut bodies, mut rest) = (Vec::new(), Vec::new(), embedded); + while !rest.is_empty() { + let (header, after) = rest.split_at(11); + assert_eq!(header[5], 0, "no references"); + let (body, after) = after.split_at(u32::from_be_bytes(header[7..11].try_into().unwrap()) as usize); + headers.extend_from_slice(header); + bodies.extend_from_slice(body); + rest = after; + } + headers.extend_from_slice(&[0, 0, 0, 2, 51, 0, 1, 0, 0, 0, 0]); + data.extend(headers); + data.extend(bodies); + data + } + + /// A stream that starts with a JBIG2 file header (ISO 14492 D.4), which some producers write + /// into `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded organization there, + /// decodes past it as Acrobat and PDFBox draw it (hayro-jbig2 patch 17), in either organization + /// the header declares, with the page count and without it. "Bit 0: File organisation type. If + /// this bit is 0, the file uses the random-access organisation. If this bit is 1, the file uses + /// the sequential organisation." (D.4.2) A header with its reserved flag bits set is an error, + /// not a panic. + #[test] + fn a_stream_with_a_jbig2_file_header_decodes_past_it() { + let (w, h) = (203u32, 41u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + let decode = |flags: u8, pages: Option| decode_jbig2(&with_file_header(&embedded, flags, pages), None, w, h, max); + // Sequential organization (bit 0 is 1), one page. + assert_eq!(decode(0x01, Some(1)).unwrap(), bits, "sequential, page count given"); + // Sequential, number of pages unknown (bit 1 is 1): no count follows (D.4.3). + assert_eq!(decode(0x03, None).unwrap(), bits, "sequential, page count unknown"); + // Random-access organization (bit 0 is 0): the segment headers, then their data. + assert_ne!(with_file_header(&embedded, 0x00, Some(1))[13..], embedded[..], "laid out otherwise"); + assert_eq!(decode(0x00, Some(1)).unwrap(), bits, "random access, page count given"); + assert_eq!(decode(0x02, None).unwrap(), bits, "random access, page count unknown"); + assert!(decode(0x11, Some(1)).is_err(), "reserved bits"); + } + + /// A file header cut short, or the segments after it, is an error and not a panic: the ID string + /// alone or in part, a header whose flags promise a page count with fewer than its four bytes, + /// a header with no segments after it, and segments cut anywhere in either organization. No + /// prefix of a whole stream decodes to its samples. + #[test] + fn a_truncated_jbig2_file_header_is_an_error() { + let (w, h) = (24u32, 8u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + let decode = |data: &[u8]| decode_jbig2(data, None, w, h, max); + assert!(decode(&FILE_HEADER_ID[..5]).is_err(), "part of the ID string"); + assert!(decode(&FILE_HEADER_ID).is_err(), "the ID string, no flags"); + for count in 0..4 { + let mut data = FILE_HEADER_ID.to_vec(); + data.push(0x01); + data.extend_from_slice(&1u32.to_be_bytes()[..count]); + assert!(decode(&data).is_err(), "{count} bytes of the page count"); + } + for (flags, pages) in [(0x01, Some(1)), (0x03, None), (0x00, Some(1)), (0x02, None)] { + let whole = with_file_header(&embedded, flags, pages); + let header = 9 + if pages.is_some() { 4 } else { 0 }; + assert!(decode(&whole[..header]).is_err(), "flags {flags:#04x}: the header alone"); + assert!(decode(&whole[..whole.len() - 1]).is_err(), "flags {flags:#04x}: the last byte cut"); + for end in 0..whole.len() { + assert_ne!(decode(&whole[..end]).ok(), Some(bits.clone()), "flags {flags:#04x}: {end} bytes"); + } + assert_eq!(decode(&whole).unwrap(), bits, "flags {flags:#04x}: whole"); + } + } + + /// The segments after a file header count toward hayro-jbig2's cap on the segments of a stream + /// together with its `/JBIG2Globals`, in either organization (patches 8 and 17): globals of + /// extension segments, which decode to nothing, fill the cap to the image's segments, and one + /// more is refused. + #[test] + fn segments_after_a_jbig2_file_header_count_with_the_globals() { + /// hayro-jbig2's `MAX_SEGMENTS`, the most segments of a stream with its globals. + const MAX_SEGMENTS: u32 = 1 << 20; + let (w, h) = (24u32, 8u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + // Numbered past the image's segments (0 to 2), of no page, with no data. + let globals = |count: u32| -> Vec { + (16..16 + count) + .flat_map(|number| { + let mut segment = number.to_be_bytes().to_vec(); + segment.extend_from_slice(&[62, 0, 0, 0, 0, 0, 0]); + segment + }) + .collect() + }; + // The image's page information and region, and in random access its end of file segment. + for (flags, organization, image) in [(0x01, "sequential", 2), (0x00, "random access", 3)] { + let data = with_file_header(&embedded, flags, Some(1)); + let (at_cap, past_cap) = (globals(MAX_SEGMENTS - image), globals(MAX_SEGMENTS - image + 1)); + assert_eq!(decode_jbig2(&data, Some(&at_cap), w, h, max).unwrap(), bits, "{organization}: at the cap"); + let refused = decode_jbig2(&data, Some(&past_cap), w, h, max).unwrap_err(); + assert!(refused.to_string().contains("too many segments"), "{organization}: {refused}"); + } + } + /// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic /// region of noise, and symbols for letters repeated across the page. With no rows asked for, /// nothing is decoded. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 92ec72afee..28339c4dc5 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4073,6 +4073,23 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[ } } +/// A `JBIG2Decode` image whose data starts with a JBIG2 file header (ISO 14492 D.4) draws, in +/// either organization the header declares (hayro-jbig2 patch 17): sequential (flags bit 0 is 1, +/// D.1) and random access (bit 0 is 0, D.2: the segment headers, ending with an end of file +/// segment, then their data). A header with reserved flag bits set leaves the image out. +#[test] +fn a_jbig2_image_with_a_file_header_draws() { + const ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; + let page = black_jbig2_page(); + let header = |flags: u8| [&ID[..], &[flags], &1u32.to_be_bytes()].concat(); + let (page_header, page_data) = page.split_at(11); + let end_of_file = [0, 0, 0, 1, 51, 0, 1, 0, 0, 0, 0]; + assert!(jbig2_image_draws(&page), "without a header"); + assert!(jbig2_image_draws(&[&header(0x01)[..], &page].concat()), "sequential"); + assert!(jbig2_image_draws(&[&header(0x00)[..], page_header, &end_of_file, page_data].concat()), "random access"); + assert!(!jbig2_image_draws(&[&header(0x11)[..], &page].concat()), "reserved flag bits"); +} + /// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with /// its own. The images differ by an extension segment: one image decoded again isn't decoded twice. #[test] diff --git a/vendor/README.md b/vendor/README.md index b398891efa..eace2a33a7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp` | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | 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); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels | (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`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images` | | 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 | diff --git a/vendor/hayro-jbig2/src/file.rs b/vendor/hayro-jbig2/src/file.rs index 5b8bbe983b..c5b59b27ca 100644 --- a/vendor/hayro-jbig2/src/file.rs +++ b/vendor/hayro-jbig2/src/file.rs @@ -88,6 +88,19 @@ pub(crate) struct File<'a> { /// (D.4.1) const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; +/// PdfCraft patch (17): an embedded stream that starts with a file header (D.4), which some +/// producers write into a PDF's `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded +/// organization (D.3) there. Returns the reader past the header and the organization the header +/// declares, or `None` when the data doesn't start with the header's ID string. +pub(crate) fn after_file_header(data: &[u8]) -> Result, FileOrganization)>> { + if !data.starts_with(&FILE_HEADER_ID) { + return Ok(None); + } + let mut reader = Reader::new(data); + let header = parse_file_header(&mut reader)?; + Ok(Some((reader, header.organization))) +} + /// Parse a standalone JBIG2 file. pub(crate) fn parse_file(data: &[u8]) -> Result> { let mut reader = Reader::new(data); @@ -170,7 +183,9 @@ fn parse_segments<'a>( FileOrganization::Sequential => { parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?; } - FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?, + FileOrganization::RandomAccess => { + parse_segments_random_access(reader, &mut segments, &mut SegmentCount::default())?; + } } Ok(segments) @@ -213,12 +228,12 @@ pub(crate) fn parse_segments_sequential<'a>( /// is followed by a sequence of segments headers; the last segment header is /// followed by the data for the first segment, then the data for the second /// segment, and so on." (D.2) -fn parse_segments_random_access<'a>( +pub(crate) fn parse_segments_random_access<'a>( reader: &mut Reader<'a>, segments: &mut Vec>, + count: &mut SegmentCount, ) -> Result<()> { let mut headers = Vec::new(); - let mut count = SegmentCount::default(); loop { if reader.at_end() { diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index e977ad72d6..7eb08d5ef3 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -75,7 +75,10 @@ pub use error::{ SegmentError, SymbolError, TemplateError, }; -use crate::file::{SegmentCount, parse_segments_sequential}; +use crate::file::{ + FileOrganization, SegmentCount, after_file_header, parse_segments_random_access, + parse_segments_sequential, +}; use bitmap::Bitmap; use decode::CombinationOperator; use decode::generic; @@ -117,7 +120,9 @@ impl<'a> Image<'a> { /// Parse an embedded JBIG2 image with optional global segments. /// /// The file is expected to use the embedded organization defined in - /// Annex D.3. + /// Annex D.3. PdfCraft patch (17): data that starts with a file header (D.4) + /// is read past it, in the organization it declares, as Acrobat and PDFBox + /// draw such streams; its segments still count together with the globals'. pub fn new_embedded(data: &'a [u8], globals: Option<&'a [u8]>) -> Result { let mut segments = Vec::new(); let mut count = SegmentCount::default(); @@ -126,8 +131,18 @@ impl<'a> Image<'a> { parse_segments_sequential(&mut reader, &mut segments, &mut count)?; }; - let mut reader = Reader::new(data); - parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + match after_file_header(data)? { + Some((mut reader, FileOrganization::RandomAccess)) => { + parse_segments_random_access(&mut reader, &mut segments, &mut count)?; + } + Some((mut reader, FileOrganization::Sequential)) => { + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + } + None => { + let mut reader = Reader::new(data); + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + } + } segments.sort_by_key(|seg| seg.header.segment_number); From 616f245b04473cd66fc7f4fc8d54220224808a84 Mon Sep 17 00:00:00 2001 From: "Oliver H." Date: Mon, 9 Nov 2026 00:54:37 +0000 Subject: [PATCH 16/29] fix(edit): break the row on every Unicode row break (#6295) --- crates/edit/src/tests.rs | 81 ++++++++++++++++++++++++++ crates/edit/src/text.rs | 2 +- crates/edit/src/text/blocks.rs | 9 +++ crates/edit/src/text/rewrite.rs | 14 ++--- crates/edit/src/text_regions.rs | 6 +- crates/edit/src/text_regions/styled.rs | 35 ++++++----- docs/editing-existing-text.md | 7 +++ parity/acrobat-features.toml | 4 +- 8 files changed, 131 insertions(+), 27 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 436809c045..a7b652a681 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13881,3 +13881,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added"); } } + +/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted +/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused +/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so +/// a break that was dropped would leave the words on one row instead of two. A one-line edit has +/// no next row, so there each break is written as a space. +#[test] +fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() { + let red = text::RangeStyle { + range: 0..3, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] { + let draft = format!("one{separator}two"); + for style in [ + text::BlockStyle::default(), + text::BlockStyle { ranges: vec![red.clone()], ..Default::default() }, + text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() }, + text::BlockStyle { bold: Some(true), ..Default::default() }, + ] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator)); + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator)); + } +} + +/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break +/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes +/// the bundled Thai face by choice or is completed by the matching one, instead of being refused +/// for a glyph no face has (#6272). +#[test] +fn thai_paragraphs_break_the_row_on_unicode_row_breaks() { + let rows = |doc: &Document| -> Vec { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() }; + let thai_face = pdfcraft_fonts::document_thai_font().is_some(); + if !thai_face { + eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked"); + } + for separator in ['\u{2028}', '\u{001C}'] { + let draft = format!("กข{separator}ค"); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator)); + if !thai_face { + continue; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator)); + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET"); + let cmap = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar", + ))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default()) + .unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator)); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2e73a5a075..a3ee880a64 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -36,13 +36,13 @@ mod interp; mod lines; mod rewrite; -use blocks::paragraph_separator; #[cfg(test)] use blocks::wrap; pub use blocks::{ BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; +use blocks::{paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index dcebb050dd..f2da128071 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool { c.is_whitespace() && c != '\u{3000}' } +/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14 +/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the +/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a +/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font +/// for an outline no face has (#6272). +pub(crate) fn row_break(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}') +} + /// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 4816271e30..c19f4ce98f 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,4 +1,4 @@ -use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; +use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, @@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet< /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { - let text = text.replace(['\n', '\r'], " "); + let text = text.replace(row_break, " "); let lines = text_lines(doc, page)?; let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; target.ensure_editable()?; @@ -240,7 +240,7 @@ fn choose_rewrite_path( } let visual_indic = members.iter().any(|line| line.visual_order); let has_special_text = text.is_some_and(|draft| { - draft.contains(['\n', '\r']) + draft.contains(row_break) || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() }); @@ -259,14 +259,14 @@ fn choose_rewrite_path( } /// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed -/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are -/// breaks too; other white space is written as one space. +/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break +/// too; other white space is written as one space. fn paragraph_text(text: &str) -> String { text.replace("\r\n", "\n") .chars() .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', + c if row_break(c) => '\n', + c if paragraph_separator(c) => ' ', c => c, }) .collect() diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 98de481444..ab9a055429 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,9 +3,9 @@ use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, - needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, - source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, - union, write_type3_glyphs, + needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font, + same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, + type3_glyph, union, write_type3_glyphs, }; #[path = "text_regions/grouping.rs"] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e6d786e8b7..d1e0738a49 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, - needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, + needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs, }; @@ -181,8 +181,8 @@ impl CharacterPreparation<'_> { "the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists" )) })?; - if !pdfcraft_fonts::embedding::covers(face.bytes, span) { - let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); + if !covers_rows(face.bytes, span) { + let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); return Err(EditError::Invalid(match missing { Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)), None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family), @@ -251,12 +251,13 @@ impl CharacterPreparation<'_> { }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + // A row break moves to the next row instead of asking a font for a glyph. The CR + // of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break + // is written as `\n`. Either way it keeps its place among the draft's characters, + // which rows are counted in. + if row_break(ch) { + let crlf = ch == '\r' + && start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); continue; } @@ -923,7 +924,7 @@ fn legacy_thai_font( if !has_thai_source_face(cache, &family) { return Ok(None); } - let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty()); + let rows = || span.split(row_break).filter(|row| !row.is_empty()); for row in rows() { if let Err(error) = shape_existing_thai(cache, &source.font, row) { // Not in the face's existing codes. A complete program of the face that shapes every @@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: } if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) || !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face)) - || !pdfcraft_fonts::embedding::covers(face.bytes, span) + || !covers_rows(face.bytes, span) { return false; } @@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { pdfcraft_fonts::embedding::validate(&bytes).is_ok() && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) - && pdfcraft_fonts::embedding::covers(&bytes, span) - && span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + && covers_rows(&bytes, span) + && span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) }) }) } +/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next +/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF. +fn covers_rows(bytes: &[u8], span: &str) -> bool { + span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row)) +} + /// Use the same Thai script selection and mark/cluster positioning as Add Text, including /// rows beginning with Latin or digits (guessing from their first character would select Latin). fn shape_thai_row(text: &str) -> Result { - if text.len() > 262_144 || text.contains(['\n', '\r']) { + if text.len() > 262_144 || text.contains(row_break) { return Err(EditError::Invalid("shape one bounded text row at a time".into())); } let clusters = diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 0124c24851..3a38487ee0 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -122,6 +122,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only at spaces. +Text pasted from a browser or a word processor often carries an invisible break instead of a +newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH +SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators +(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a +row rather than being looked up in a font, so a paste no longer fails because no face has an +outline for them. Other white space is still written as one space. + Select text within the editor before changing size, colour, font, underline or spacing to format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the same properties; invalid or overlapping ranges fail atomically. Untouched source formatting diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 70a0cc34df..6604bafd4e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1895,8 +1895,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" From c67c44bf2156ce09eccc1d9ebd994f1de56f293d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:55:14 +0800 Subject: [PATCH 17/29] M10: applying recognized words refreshes only the page content they add (#6310) (#6316) --- crates/engine/src/history.rs | 6 ++++- crates/engine/src/lib.rs | 5 +++- crates/engine/src/session/editing.rs | 2 ++ crates/engine/src/test_hooks.rs | 14 ++++++++++ crates/engine/src/tests.rs | 39 ++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 6b204ec372..04d848eb8d 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -95,7 +95,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, + | Edit::RemoveMarks { .. } + // Recognized text is an invisible text layer stamped on its page, with its font in the + // page's resources (#6310): a full re-inspection made applying one word cost seconds on + // a large file. + | Edit::AddOcrText { .. } => Scope::Content, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c6272a0abf..ce0bb5c784 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -161,7 +161,10 @@ 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 test_hooks::{ + check_refresh_panic, panic_on_refresh, record_full_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, + secondary_scan_counts, take_full_refreshes, +}; 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/session/editing.rs b/crates/engine/src/session/editing.rs index 05cf718d4d..ddbd6d1014 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -515,6 +515,8 @@ impl Session { } fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + #[cfg(test)] + crate::record_full_refresh(); // 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() { diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs index d6e504599e..1f6bd94822 100644 --- a/crates/engine/src/test_hooks.rs +++ b/crates/engine/src/test_hooks.rs @@ -40,3 +40,17 @@ pub(crate) fn record_link_scan() { counts.set((markers, links + 1)); }); } + +thread_local! { + static FULL_REFRESHES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A whole-document refresh (re-inspection) ran on this thread. +pub(crate) fn record_full_refresh() { + FULL_REFRESHES.with(|n| n.set(n.get() + 1)); +} + +/// How many whole-document refreshes ran on this thread since the last call, and starts over. +pub(crate) fn take_full_refreshes() -> usize { + FULL_REFRESHES.with(|n| n.replace(0)) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2d8eab60db..ad17d569fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9066,6 +9066,45 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both"); } +/// Recognized text is an invisible text layer on its page, so applying it refreshes like any +/// other content edit, not by re-inspecting the whole document (#6310): on a 567-page file with +/// 60,102 font resources one word took 2.4 s, the full refresh nearly all of it. The page tree, +/// comments, fields, links and marks stay as they were; the words are on their page, the font +/// list picks up the OCR font on `refresh_fonts`, undo and redo take the words out and back +/// without a full refresh either, and a save keeps them. +#[test] +fn ocr_words_refresh_only_the_page_content_they_add() { + use crate::ocr::{OcrPage, PlacedWord}; + let (mut s, id) = session_with(3); + let words_on = |s: &Session, page: usize| s.get(id).unwrap().text_lines(page).iter().map(|l| l.text.trim().to_string()).collect::>(); + let before = s.get(id).unwrap(); + let view = format!("{:?}", (&before.info.pages, &before.info.annotations, &before.info.fields, &before.links, before.marks())); + let others = (words_on(&s, 0), words_on(&s, 2)); + assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO"))); + take_full_refreshes(); + let word = PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }; + assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None }]).unwrap(), 1); + assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document"); + let after = s.get(id).unwrap(); + assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view); + assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "{:?}", words_on(&s, 1)); + assert_eq!((words_on(&s, 0), words_on(&s, 2)), others, "the other pages are untouched"); + s.refresh_fonts(id); + let d = s.get(id).unwrap(); + let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); + assert_eq!(format!("{:?}", d.info.fonts), format!("{:?}", full.fonts), "the font list is what a full inspection reads"); + assert_eq!(d.info.file_size, full.file_size); + s.undo(id).unwrap(); + assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")), "undo takes the words out"); + s.redo(id).unwrap(); + assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "redo puts them back"); + assert_eq!(take_full_refreshes(), 0, "undo and redo refresh the content alone too"); + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let rid = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert!(reopened.get(rid).unwrap().text_lines(1).iter().any(|l| l.text.trim() == "HELLO"), "the save keeps the words"); +} + /// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list /// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it. #[test] From 61ce0cb0100d5fd55b339d13314c38512375b35d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:55:29 +0800 Subject: [PATCH 18/29] M6: a multi-select list's picks reach its XFA data as one value element each (form.fill-choice) (#6328) --- crates/xfa/src/data.rs | 23 +++++-- crates/xfa/src/data/merge.rs | 76 ++++++++++++++++++++++ crates/xfa/src/data/path.rs | 10 +-- crates/xfa/src/tests.rs | 123 +++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index cb3b80a0c7..09c8252fa8 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -55,6 +55,10 @@ pub struct DataNode { pub name: String, pub text: String, pub children: Vec, + /// Whether the element is a data group even with no children: marked + /// `xfa:dataNode="dataGroup"` (XFA 3.3 p. 150), or a multi-select list's group emptied of its + /// picks here, which the packet writer marks so. + pub group: bool, } /// A path into the data: `(name, index among same-named siblings)` per level. @@ -150,10 +154,21 @@ impl DataNode { pub fn apply(&mut self, op: &DataOp) { match op { DataOp::Value { path, text } => { - if let Some(n) = self.ensure(path) - && n.children.is_empty() - { - n.text = text.clone(); + if let Some(n) = self.ensure(path) { + // A multi-select list's picks, one `value` each (see `value_at`), told apart + // as the packet writer does: `value` leaves only, or a group holding none. + let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) }; + if picks { + n.children = text + .split('\n') + .filter(|v| !v.is_empty()) + .map(|v| DataNode { name: "value".into(), text: v.to_string(), ..Default::default() }) + .collect(); + // Emptied, it is still a group, and takes picks again. + n.group = true; + } else if n.children.is_empty() { + n.text = text.clone(); + } } } DataOp::Instances { parent, name, count } => { diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 0aefc634b5..0d573c71a9 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -271,6 +271,44 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg Merged::Changed(out) } +/// Whether `node` is a multi-select choice list's data group: element children only, every one +/// a `value` holding text alone; or, with no children, an element marked `xfa:dataNode= +/// "dataGroup"`, as a group with no picks is written (XFA 3.3 p. 150). +fn is_value_group(node: roxmltree::Node) -> bool { + let blank = node.children().filter(|c| c.is_text()).all(|c| c.text().is_some_and(|t| t.trim().is_empty())); + let mut elements = node.children().filter(|c| c.is_element()).peekable(); + if elements.peek().is_none() { + return blank && marked_data_group(node); + } + blank && elements.all(|c| c.tag_name().name() == "value" && !c.children().any(|g| g.is_element())) +} + +/// Whether `node` carries `xfa:dataNode="dataGroup"`. +fn marked_data_group(node: roxmltree::Node) -> bool { + node.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup") +} + +/// The edit that marks the data group `node` as one, so that emptied of its picks it still loads +/// as a group and not as a data value (XFA 3.3 p. 150): `xfa:dataNode="dataGroup"` added to its +/// start tag, with the namespace declared there when the packet has no prefix for it. +fn mark_data_group_edit(src: &str, node: roxmltree::Node) -> Option { + let range = node.range(); + let tag_end = start_tag_end(src, &range)?; + // Before `/>` of an empty tag, else before `>`. + let at = if src.get(..tag_end)?.ends_with('/') { tag_end.checked_sub(1)? } else { tag_end }; + let text = match node.lookup_prefix(XFA_DATA_NS) { + Some(prefix) if !prefix.is_empty() => format!(" {prefix}:dataNode=\"dataGroup\""), + _ => { + // A prefix no namespace in scope is bound to, so declaring it rebinds nothing. + let prefix = (0..16) + .map(|i| if i == 0 { "pcxfa".to_string() } else { format!("pcxfa{i}") }) + .find(|p| node.lookup_namespace_uri(Some(p)).is_none())?; + format!(" xmlns:{prefix}=\"{XFA_DATA_NS}\" {prefix}:dataNode=\"dataGroup\"") + } + }; + Some(Edit { start: at, end: at, text }) +} + /// The edits that put `wants` into the `data` element. fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) { // Element children by local name, per element visited (built once each). @@ -311,6 +349,44 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve continue; } let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + // A multi-select choice list is bound to a data group of `value` elements, one per + // pick, and its value is theirs joined by newlines (XFA 3.3 p. 198); a new pick + // replaces them all, as in Acrobat (p. 199). + if is_value_group(node) { + // Every text segment of each `value`, as `current` reads a leaf (a comment may + // split or lead it). + let held: Vec = node + .children() + .filter(|c| c.is_element()) + .map(|c| c.children().filter(|t| t.is_text()).filter_map(|t| t.text()).collect()) + .collect(); + // Pick by pick: one `value` holding a line break is not two picks. + let wanted: Vec<&str> = w.text.split('\n').filter(|v| !v.is_empty()).collect(); + if held.iter().map(String::as_str).eq(wanted.iter().copied()) { + continue; + } + let inner: String = wanted.iter().map(|v| format!("{}", escape(v).0)).collect(); + // Emptied, the group keeps saying it is one (XFA 3.3 p. 150). + let needs_mark = wanted.is_empty() && !marked_data_group(node); + let mark = if needs_mark { mark_data_group_edit(src, node) } else { None }; + if needs_mark && mark.is_none() { + // No marker can be written (no free prefix): emptied, the group would load + // as a data value, so it keeps its picks and the skip is reported. + skipped(warnings, STRUCTURED, &w.som); + continue; + } + match replace_edit(src, node, &inner) { + Some(e) => { + edits.extend(mark); + edits.push(e); + } + None => skipped(warnings, STRUCTURED, &w.som), + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + continue; + } if node.children().any(|c| c.is_element()) { let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); // Ignore existing XML indentation, but not whitespace in the accepted value. diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 71f089112e..fd20b39d81 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -1,12 +1,14 @@ -use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template}; +use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template, XFA_DATA_NS}; fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { let name = n.tag_name().name().to_string(); let children: Vec = if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() }; - // The text of a leaf is its value; a container's own text is whitespace. - let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() }; - DataNode { name, text, children } + // The text of a leaf is its value (its text nodes: a comment is not part of it); a + // container's own text is whitespace. + let text = if children.is_empty() { n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::() } else { String::new() }; + let group = n.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup"); + DataNode { name, text, children, group } } /// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d688c8312c..43d6633c56 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4815,3 +4815,126 @@ fn a_template_inside_the_data_is_not_the_form() { let real = ""; assert_eq!(parse(real).unwrap().0.root.common.name.as_deref(), Some("form1")); } + +/// A multi-select choice list is bound to a data group of `value` elements, one per pick, and its +/// value is theirs joined by newlines (XFA 3.3 p. 198). New picks replace the group's `value` +/// elements, values off the list included, as Acrobat discards them (p. 199): the group was left +/// as it was, so the old picks came back on reading, in the datasets and in the scripts' data. +#[test] +fn a_multi_select_lists_data_group_takes_its_new_picks() { + let shell = + crate::fixtures::static_shell("ryebarleywheat"); + let mut doc = Document::open(Arc::new(shell)).unwrap(); + let path = som_to_path("form1[0].page1[0].grains[0]"); + assert!(write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written); + let data = crate::script::data_of(&doc).unwrap(); + assert_eq!(data.value_at(&path).as_deref(), Some("wheat\nmillet & oats")); + assert_eq!(data.count(&path, "value"), 2, "one value element per pick"); + assert!(!write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written, "the same picks change nothing"); + assert!(write_data_value(&mut doc, &path, "").unwrap().written); + assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some(""), "nothing picked"); + // Emptied, the group is marked as one (XFA 3.3 p. 150), so it is still written as picks. + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains(""), "{xdp}"); + assert!( + write_data_value( + &mut doc, + &path, + "rye +millet" + ) + .unwrap() + .written + ); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2, "picks again, not text"); + // A pick split or led by a comment is read whole: the same pick is no change, and clearing + // a pick that reads as empty through its first segment alone still clears it. + let commented = + crate::fixtures::static_shell("ryewheat"); + let mut doc = Document::open(Arc::new(commented)).unwrap(); + assert!( + !write_data_value( + &mut doc, + &path, + "rye +wheat" + ) + .unwrap() + .written, + "the same picks, read past the comments" + ); + assert_eq!( + crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), + Some( + "rye +wheat" + ), + "and read back without them" + ); + assert!(write_data_value(&mut doc, &path, "").unwrap().written, "cleared"); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 0); + // Where `xfa` names another namespace and `pcxfa` is taken too, the marker's prefix is a free + // one, declared on the group, so no attribute in scope changes meaning. + let shadowed = crate::fixtures::static_shell( + "rye", + ); + let mut doc = Document::open(Arc::new(shadowed)).unwrap(); + assert!(write_data_value(&mut doc, &path, "").unwrap().written); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("xmlns:pcxfa1=\"http://www.xfa.org/schema/xfa-data/1.0/\" pcxfa1:dataNode=\"dataGroup\""), "{xdp}"); + assert!(xdp.contains("xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\""), "the taken prefix keeps its namespace: {xdp}"); + // With every candidate prefix taken, no marker can be written: the group keeps its pick + // and the skip is reported, rather than emptied into what would load as a data value. + let taken: String = (0..16).map(|i| if i == 0 { " xmlns:pcxfa=\"urn:t\"".to_string() } else { format!(" xmlns:pcxfa{i}=\"urn:t\"") }).collect(); + let crowded = + crate::fixtures::static_shell(&format!("rye")); + let mut doc = Document::open(Arc::new(crowded)).unwrap(); + let r = write_data_value(&mut doc, &path, "").unwrap(); + assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured content")), "{:?}", r.warnings); + assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some("rye")); + // One `value` whose text holds a line break is one pick, not the two it would join to. + let one = crate::fixtures::static_shell( + "one +two", + ); + let mut doc = Document::open(Arc::new(one)).unwrap(); + assert!( + write_data_value( + &mut doc, &path, "one +two" + ) + .unwrap() + .written, + "two picks replace the one" + ); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2); + // The in-memory data, which scripts read before the packet is written, follows the same rule. + let mut tree = crate::script::data_of( + &Document::open(Arc::new(crate::fixtures::static_shell("rye"))).unwrap(), + ) + .unwrap(); + tree.apply(&DataOp::Value { path: path.clone(), text: "millet\nwheat".into() }); + assert_eq!(tree.value_at(&path).as_deref(), Some("millet\nwheat")); + // Emptied in memory, the group still takes picks, not text. + tree.apply(&DataOp::Value { path: path.clone(), text: String::new() }); + assert_eq!((tree.count(&path, "value"), tree.value_at(&path).as_deref()), (0, Some(""))); + tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() }); + assert_eq!((tree.count(&path, "value"), tree.text_at(&path)), (2, Some("")), "picks again, not text"); + assert_eq!(tree.value_at(&path).as_deref(), Some("rye\nmillet")); + let tree_of = |data: &str| crate::script::data_of(&Document::open(Arc::new(crate::fixtures::static_shell(data))).unwrap()).unwrap(); + // A group read emptied, as the packet writer marks it, takes picks the same way. + let mut tree = tree_of(""); + tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() }); + assert_eq!(tree.count(&path, "value"), 2); + // A node holding anything but `value` leaves is not a list's picks: it is left as it was, + // as the packet writer leaves it (and reports the skip). + for structured in [ + "ryex", + "rye", + ] { + let mut tree = tree_of(structured); + let before = tree.clone(); + tree.apply(&DataOp::Value { path: path.clone(), text: "millet".into() }); + assert_eq!(tree, before, "{structured}"); + } +} From 3e437b7bd47ed2b4435e4f8a190157f6bafca4b9 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:56:16 +0800 Subject: [PATCH 19/29] M10: comments and fields without an appearance print as the page shows them (#6367) --- crates/engine/src/document.rs | 10 +++++++- crates/engine/src/session/files.rs | 7 ++++++ crates/engine/src/tests.rs | 39 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 08654f6752..728b9e3615 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -728,6 +728,14 @@ pub(crate) fn write_display_copy( } } +/// `doc` as the reader shows it: comments without an appearance stream drawn +/// (`pdfcraft_annot::with_missing_appearances`) and form fields drawn from their values where the +/// file leaves that to the viewer. `None` when `doc` needs neither. The vector print draws from +/// this copy too, so what prints is what the page shows (#371, #3475 draw them for display only). +pub(crate) fn as_displayed(doc: &pdfcraft_cos::Document) -> Option { + with_field_appearances(pdfcraft_annot::with_missing_appearances(doc), doc) +} + /// Carry the marks across a revalidation that leaves the display copy alone, when it may: only /// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the /// copy has to be drawn again. @@ -779,7 +787,7 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc (display, true), None if layered => (cos, false), None => return None, diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index b787e4d6e2..61845be75c 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -164,6 +164,13 @@ impl Session { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // Comments and fields without an appearance print as the page shows them (and as Print + // as Image already printed them): the vector print drew only appearance streams, so a + // text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an + // annotation with the Print flag is printed. + if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() { + cos = displayed; + } // #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. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad17d569fe..553b2cb939 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1779,6 +1779,45 @@ fn low_resolution_printing_prints_only_the_chosen_content() { } } +/// A comment and a field the file leaves without an appearance print as the page shows them: +/// a text box (`/FreeText` with only `/Contents` and `/DA`) and a check box set on with +/// `/NeedAppearances`. The vector print drew only appearance streams, so both were on screen and +/// in Print as Image but not on paper (pdf.js `bug1865341`, `freetext_print_no_appearance`). +/// ISO 32000-2 §12.5.3: an annotation with the Print flag is printed. +#[test] +fn comments_and_fields_without_an_appearance_print_as_shown() { + let content = ""; + let pdf = format!( + "%PDF-1.7 + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >> >> endobj + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [5 0 R 6 0 R] >> endobj + 4 0 obj << /Length {} >> stream +{content} +endstream endobj + 5 0 obj << /Type /Annot /Subtype /FreeText /F 4 /P 3 0 R /Rect [10 120 190 190] /DA (/Helv 40 Tf 0 g) /Contents (WWWW) >> endobj + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (Box) /V /Yes /AS /Yes /F 4 /P 3 0 R /Rect [10 10 110 110] /MK << /CA (8) >> >> endobj + trailer << /Root 1 0 R >> +%%EOF", + content.len(), + ); + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap(); + let settings = + print::Settings { pages: vec![0], paper: (200.0, 200.0), layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (200, 200)); + // Dark pixels in the text box (top half) and in the check box (bottom half). + let dark = |y0: usize, y1: usize| { + (y0..y1) + .flat_map(|y| (0..w as usize).map(move |x| (x, y))) + .filter(|&(x, y)| rgba.get((y * w as usize + x) * 4).is_some_and(|&r| r < 128)) + .count() + }; + assert!(dark(10, 80) > 50, "the text box prints"); + assert!(dark(90, 190) > 50, "the check box prints"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, From bb8fbd6b677c97b871e77c2fd2875903007acb88 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:56:30 +0800 Subject: [PATCH 20/29] fix(ui-egui): stop held middle-button auto-scroll on release (#3795) (#6544) --- crates/ui-egui/src/canvas/autoscroll.rs | 120 +++++++++++++++++++++++- crates/ui-egui/tests/it/comments.rs | 8 +- crates/ui-egui/tests/it/control.rs | 15 ++- 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/canvas/autoscroll.rs b/crates/ui-egui/src/canvas/autoscroll.rs index 1f4cd62c5d..5a04d1c193 100644 --- a/crates/ui-egui/src/canvas/autoscroll.rs +++ b/crates/ui-egui/src/canvas/autoscroll.rs @@ -17,6 +17,9 @@ const MAX_DISPLACEMENT: f32 = 1_000_000.0; pub(crate) struct AutoScroll { anchor: Option, organize: bool, + /// Consecutive or total frames spent past [`DEAD_ZONE`] while the initiating middle press + /// remained held (`>= 2` distinguishes a held scroll from a quick click). + held_frames_past_dead_zone: u8, /// Own a cancelling click through its release, so it cannot also edit page content. cancel_button: Option, block_input: bool, @@ -36,6 +39,7 @@ impl AutoScroll { pub(crate) fn cancel(&mut self) { self.anchor = None; + self.held_frames_past_dead_zone = 0; self.drag = None; self.cancel_button = None; self.block_input = false; @@ -59,7 +63,7 @@ impl AutoScroll { fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 { let ctx = ui.ctx(); - let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| { + let (pointer, middle_press, middle_down, middle_released, has_release_event, cancel, cancel_button, dt) = ctx.input(|i| { let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2] .into_iter() .find(|button| i.pointer.button_pressed(*button)); @@ -72,6 +76,7 @@ impl AutoScroll { }), i.pointer.button_down(PointerButton::Middle), i.pointer.button_released(PointerButton::Middle), + i.events.iter().any(|e| matches!(e, Event::PointerButton { button: PointerButton::Middle, pressed: false, .. })), !i.focused || i.key_pressed(Key::Escape) || cancel_button.is_some() @@ -80,7 +85,8 @@ impl AutoScroll { i.stable_dt, ) }); - self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released; + self.block_input = + self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released || has_release_event; if let Some(button) = self.cancel_button { if !ctx.input(|i| i.pointer.button_down(button)) { self.cancel_button = None; @@ -106,13 +112,23 @@ impl AutoScroll { { self.anchor = Some(pressed_at); self.organize = organize; + self.held_frames_past_dead_zone = 0; } } let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO }; let displacement = pointer.y - anchor.y; + let past_dead_zone = displacement.is_finite() && displacement.abs() > DEAD_ZONE; + if middle_down && !has_release_event && past_dead_zone { + self.held_frames_past_dead_zone = self.held_frames_past_dead_zone.saturating_add(1); + } + if (middle_released || has_release_event) && self.held_frames_past_dead_zone >= 2 { + self.cancel(); + self.block_input = true; + return Vec2::ZERO; + } // Cap the elapsed time too: returning from an idle/hidden window must never jump pages. let delta = scroll_delta(displacement, dt); - if displacement.is_finite() && displacement.abs() > DEAD_ZONE { + if past_dead_zone { // Continuous redraws let stable_dt use measured frame time. Delayed redraws // instead use predicted_dt, which can make speed depend on the actual frame rate. ctx.request_repaint(); @@ -124,6 +140,7 @@ impl AutoScroll { /// blocked from the press through the release, so the drag cannot also draw, select or move. fn update_drag(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 { self.anchor = None; + self.held_frames_past_dead_zone = 0; self.cancel_button = None; let ctx = ui.ctx(); let (pointer, middle_press, middle_down, middle_released, focused) = ctx.input(|i| { @@ -319,6 +336,103 @@ mod tests { assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still"); } + #[test] + fn holding_middle_button_to_auto_scroll_turns_off_on_release() { + let ctx = Context::default(); + let mut scroll = AutoScroll::default(); + let p = egui::pos2(100.0, 100.0); + let far = p + vec2(0.0, 60.0); + frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, d| { + assert!(s.active() && s.blocks_input()); + assert_eq!(d, Vec2::ZERO); + }); + for _ in 0..2 { + frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| { + assert!(s.active() && s.blocks_input()); + assert!(d.y < 0.0, "holding and moving past the dead zone scrolls: {d:?}"); + }); + } + // Primary case: releasing while still past the dead zone stops auto-scroll immediately. + frame(&ctx, &mut scroll, vec![middle(far, false)], true, (true, false), |s, d| { + assert!(!s.active(), "releasing at +60pt after holding must turn auto-scroll off"); + assert!(s.blocks_input(), "the release frame itself still blocks page tools"); + assert_eq!(d, Vec2::ZERO); + }); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| { + assert!(!s.active() && !s.blocks_input()); + assert_eq!(d, Vec2::ZERO); + }); + + // Returning inside the dead zone before releasing still counts as a held scroll gesture. + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active())); + for _ in 0..2 { + frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| assert!(s.active() && d.y < 0.0)); + } + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, 5.0)), middle(p + vec2(0.0, 5.0), false)], true, (true, false), |s, d| { + assert!(!s.active(), "releasing after scrolling while held must turn auto-scroll off"); + assert!(s.blocks_input()); + assert_eq!(d, Vec2::ZERO); + }); + } + + #[test] + fn middle_click_released_within_dead_zone_stays_latched() { + let ctx = Context::default(); + let mut scroll = AutoScroll::default(); + let p = egui::pos2(100.0, 100.0); + let far = p + vec2(0.0, 60.0); + frame(&ctx, &mut scroll, vec![], true, (true, false), |_, _| {}); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active())); + frame( + &ctx, + &mut scroll, + vec![Event::PointerMoved(p + vec2(0.0, DEAD_ZONE)), middle(p + vec2(0.0, DEAD_ZONE), false)], + true, + (true, false), + |s, d| { + assert!(s.active(), "releasing within the dead zone keeps auto-scroll latched"); + assert_eq!(d, Vec2::ZERO); + }, + ); + frame(&ctx, &mut scroll, vec![Event::PointerMoved(far)], true, (true, false), |s, d| { + assert!(s.active()); + assert!(d.y < 0.0); + }); + scroll.cancel(); + + // Pointer movement after middle-button release in the same frame does not count as held movement. + frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (true, false), |s, _| assert!(s.active())); + frame(&ctx, &mut scroll, vec![middle(p, false), Event::PointerMoved(far)], true, (true, false), |s, d| { + assert!(s.active(), "movement after release in the same frame keeps auto-scroll latched"); + assert!(d.y < 0.0); + }); + scroll.cancel(); + + // A press, move, and release arriving in a single frame is a click at the initial press position, + // and stays latched across subsequent frames when egui delivers its deferred button_released state + // or runs multiple layout passes within the activation frame. + frame( + &ctx, + &mut scroll, + vec![Event::PointerMoved(p), middle(p, true), Event::PointerMoved(far), middle(far, false)], + true, + (true, false), + |s, d| { + assert!(s.active(), "single-frame click and move latches auto-scroll"); + assert!(d.y < 0.0); + }, + ); + frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| { + assert!(s.active(), "deferred button_released in the next frame must keep auto-scroll latched"); + assert!(d.y < 0.0); + }); + frame(&ctx, &mut scroll, vec![], true, (true, false), |s, d| { + assert!(s.active(), "subsequent frames after deferred release must keep auto-scroll latched"); + assert!(d.y < 0.0); + }); + } + #[test] fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() { let ctx = Context::default(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 532c45de91..fa387f9a18 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2790,16 +2790,12 @@ fn a_middle_drag_with_a_drawing_tool_scrolls_instead_of_drawing() { assert!(!s.views[0].middle_panning(), "releasing the wheel ends the pan"); let moved = s.views[0].page_screen_rect(0).expect("page 1 on screen").top(); if cfg!(target_os = "linux") { - // Linux latches auto-scroll instead of panning with the drag. - assert!(s.views[0].auto_scrolling()); + // Linux auto-scrolls while the wheel is held and stops on release. + assert!(!s.views[0].auto_scrolling()); } else { assert!((moved - (top - 120.0)).abs() < 1.0, "the page follows the pointer: {top} -> {moved}"); } // The tool still draws with the primary button afterwards. - if cfg!(target_os = "linux") { - h.key_press(egui::Key::Escape); - h.run_steps(2); - } drag_pt(&mut h, (60.0, 120.0), (200.0, 80.0)); let kinds: Vec = comments(&h).into_iter().map(|a| a.subtype).collect(); assert_eq!(kinds, ["Ink"]); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 1b9cb85a39..02b21d1692 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -665,13 +665,21 @@ fn moving_before_middle_button_release_keeps_scrolling_without_starting_page_too ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 60.0], "steps": 12, "button": "middle" })); h.run_steps(2); assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0); - assert!(h.state().views[0].auto_scrolling(), "release keeps scrolling toggled on even after movement"); + assert!(!h.state().views[0].auto_scrolling(), "releasing after holding and scrolling turns auto-scroll off"); let released = h.state().views[0].page_screen_rect(0).unwrap().top(); h.run_steps(8); - assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held"); + assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), released, "no drift after releasing a held middle drag"); assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag"); assert!(h.state().dialog.is_none()); assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); + + // Small movement within the dead zone before release still latches auto-scroll on. + ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 10.0], "steps": 4, "button": "middle" })); + assert!(h.state().views[0].auto_scrolling(), "release within the dead zone keeps scrolling toggled on"); + ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 60.0 })); + h.run_steps(8); + assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held"); + assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag"); ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y + 60.0, "button": "middle" })); assert!(!h.state().views[0].auto_scrolling(), "the next middle click toggles scrolling off"); } @@ -886,7 +894,8 @@ fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() { assert!(h.state().views[0].selected.is_empty()); assert!(h.state().views[0].org_drag.is_none()); assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); - assert!(h.state().views[0].auto_scrolling(), "releasing the wheel leaves the grid scrolling on"); + assert!(!h.state().views[0].auto_scrolling(), "releasing a held wheel stops the grid scrolling"); + start_autoscroll(&mut h, &c); h.state_mut().views[0].organize = false; h.run_steps(2); assert!(!h.state().views[0].auto_scrolling(), "changing canvas mode cancels the gesture"); From 04062a047e94b94d3b1c468fc9261f922073c0cb Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:56:52 +0800 Subject: [PATCH 21/29] M5: the hotbar settings button shows one tooltip (#6548) (#6550) --- crates/ui-egui/src/hotbar.rs | 2 +- crates/ui-egui/src/widgets/icons.rs | 9 +++++++- crates/ui-egui/tests/it/comments.rs | 34 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 5075ffee2e..077d899dfe 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -112,7 +112,7 @@ impl Hotbar { fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) -> bool { let settings = ui .push_id("hotbar-settings", |ui| { - icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools")) + icons::labeled_button(ui, "settings", cell, false, tl!("Hotbar settings"), tl!("Hotbar settings / add tools")) }) .inner; if settings.clicked() { diff --git a/crates/ui-egui/src/widgets/icons.rs b/crates/ui-egui/src/widgets/icons.rs index 13f50a0d6c..aae365bba3 100644 --- a/crates/ui-egui/src/widgets/icons.rs +++ b/crates/ui-egui/src/widgets/icons.rs @@ -34,7 +34,14 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) { } /// Square icon button: transparent until hovered; `selected` gets the accent treatment. +/// `tooltip` is both its accessible name and its one tooltip. pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response { + labeled_button(ui, name, box_size, selected, tooltip, tooltip) +} + +/// [`button`] whose tooltip says more than its accessible name (`label`). It shows that one +/// tooltip, so callers add no `on_hover_text` of their own (#6548: two tooltips stacked). +pub fn labeled_button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, label: &str, tooltip: &str) -> Response { let t = Tokens::get(ui.ctx()); // The shared button paints no fill of its own: the eased press wash below replaces it. let clear = |icon| ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon }; @@ -46,7 +53,7 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool corner_radius: t.radius.into(), focus_stroke: Stroke::new(1.0, t.accent), }; - let label = if tooltip.is_empty() { name } else { tooltip }; + let label = if label.is_empty() { name } else { label }; // The wash eases from the response, so reserve its place under the icon and focus ring. let wash = ui.painter().add(egui::Shape::Noop); let mut painted = None; diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index fa387f9a18..c9ab522ecd 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3590,6 +3590,40 @@ fn hotbar_picker_adds_and_runs_freehand_highlight() { assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); } +/// #6548: resting on the hotbar's gear showed two stacked tooltips, "Hotbar settings" and +/// "Hotbar settings / add tools". It shows the longer one only and keeps its short name. +#[test] +fn hotbar_settings_button_shows_one_tooltip() { + let mut h = harness(|_| {}); + h.run_steps(2); + h.get_by_label("Hotbar settings").hover(); + // egui shows a tooltip once the pointer has rested `tooltip_delay` seconds of *input* time, + // and the harness advances that clock by a fixed step every frame, so the wait is counted in + // simulated seconds rather than slept: it cannot pass early or depend on the machine's speed. + let delay = f64::from(h.ctx.global_style().interaction.tooltip_delay); + let start = h.ctx.input(|i| i.time); + let elapsed = |h: &Harness<'static, PdfCraftApp>| h.ctx.input(|i| i.time) - start; + // The button's name; a second tooltip repeating it would make two. Checked after every + // frame, so one that shows only briefly is caught too. + let names = |h: &Harness<'static, PdfCraftApp>| h.query_all_by_label("Hotbar settings").count(); + let mut frames = 0; + while h.query_all_by_label("Hotbar settings / add tools").next().is_none() { + assert!(elapsed(&h) < 4.0 * delay && frames < 100, "no tooltip after {:.2}s of rest", elapsed(&h)); + h.step(); + frames += 1; + assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h)); + } + // Rest two more delays, so a second tooltip would have had its chance to open. + let shown = elapsed(&h); + while elapsed(&h) < shown + 2.0 * delay && frames < 100 { + h.step(); + frames += 1; + assert_eq!(names(&h), 1, "a second tooltip at {:.2}s", elapsed(&h)); + } + assert_eq!(h.query_all_by_label("Hotbar settings / add tools").count(), 1, "the tooltip shows"); + assert_eq!(names(&h), 1, "the button's name, and no second tooltip"); +} + #[test] fn hotbar_picker_searches_translated_tool_labels() { if crate::support::skip_without_catalogs("hotbar_picker_searches_translated_tool_labels") { From 3e48317ae3594cb46e20447860532e5271d55362 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:57:06 +0800 Subject: [PATCH 22/29] M10: Pages per sheet takes a custom grid of columns by rows (#6551) (#6559) --- crates/automation/src/printing.rs | 20 +++++++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 43 ++++++++++++++++ crates/print/README.md | 5 +- crates/print/src/lib.rs | 2 +- crates/print/src/settings.rs | 21 +++++++- crates/print/src/sheets.rs | 11 ++-- crates/print/src/tests.rs | 50 +++++++++++++++++-- crates/ui-egui/src/workflows/print_ui/body.rs | 33 ++++++++++-- .../ui-egui/src/workflows/print_ui/draft.rs | 24 +++++++-- crates/ui-egui/tests/it/print.rs | 32 ++++++++++++ parity/acrobat-features.toml | 4 +- 12 files changed, 224 insertions(+), 25 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..2f1cf5e136 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -134,6 +134,18 @@ impl Automation { "shrink" => Layout::Size(SizeMode::Shrink), "custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)), "multiple" => { + // Custom pages per sheet: `cols` across by `rows` down, both or neither. + let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) { + (None, None) => None, + (Some(cols), Some(rows)) => { + if a.opt_int("per_sheet")?.is_some() { + return Err(bad("give per_sheet, or cols and rows, not both")); + } + let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows)); + Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?) + } + _ => return Err(bad("a custom grid needs both cols and rows")), + }; let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize; let order = match a.opt_str("order")?.unwrap_or("horizontal") { "horizontal" => PageOrder::Horizontal, @@ -143,14 +155,15 @@ impl Automation { "cut-stack" => PageOrder::CutStack, o => return Err(bad(format!("unknown order {o:?}"))), }; - match Layout::multiple(per) { - Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple { + match grid.unwrap_or_else(|| Layout::multiple(per)) { + Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple { cols, rows, per_sheet, order, border: a.opt_bool("border")?.unwrap_or(false), auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true), + custom, }, l => l, } @@ -172,6 +185,9 @@ impl Automation { l => return Err(bad(format!("unknown layout {l:?}"))), }; let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; + if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { + return Err(bad("cols and rows need layout multiple")); + } if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c123ef49c9..227672df06 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -707,7 +707,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ @@ -718,6 +718,8 @@ pub fn tools() -> Vec { "layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] }, "scale": { "type": "number", "exclusiveMinimum": 0 }, "per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 }, + "cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." }, + "rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." }, "order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] }, "border": { "type": "boolean" }, "auto_rotate": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 1192050e51..d68cd87f51 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8915,6 +8915,49 @@ fn cut_stack_printing_through_tools() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn custom_pages_per_sheet_prints_the_grid_as_given() { + // Two narrow pages side by side on portrait A4: 2 columns by 1 row (#6551). + let dir = workdir("custom-grid"); + std::fs::write(dir.join("narrow.pdf"), fixture(4)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "narrow.pdf"}))["doc"].as_u64().unwrap(); + for orientation in ["auto", "portrait"] { + let r = ok( + &mut a, + "doc_print", + json!({"doc": doc, "layout": "multiple", "cols": 2, "rows": 1, "paper": "A4", "orientation": orientation, "path": "grid.pdf"}), + ); + assert_eq!(r["sheets"], 2, "{orientation}"); + let printed = ok(&mut a, "doc_open", json!({"path": "grid.pdf"}))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({"doc": printed})); + let sheet = &info["pages"][0]; + assert!(sheet["width"].as_f64().unwrap() < sheet["height"].as_f64().unwrap(), "portrait sheet with {orientation}: {sheet}"); + // Side by side: the second page level with the first, to its right. + let rows = ok(&mut a, "text_extract", json!({"doc": printed, "rect": [0, 0, 600, 850]})); + let rows: Vec<&str> = rows["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim()).collect(); + assert_eq!(rows, ["Page 1 Page 2", "Page 3 Page 4"], "{orientation}"); + ok(&mut a, "doc_close", json!({"doc": printed})); + } + for bad in [ + json!({"cols": 2}), + json!({"rows": 1}), + json!({"cols": 0, "rows": 1}), + json!({"cols": 2, "rows": 100}), + json!({"cols": -1, "rows": 2}), + json!({"cols": 2, "rows": 1, "per_sheet": 2}), + json!({"cols": 2, "rows": 1, "layout": "fit"}), + ] { + let mut args = json!({"doc": doc, "layout": "multiple", "path": "refused.pdf"}); + for (k, v) in bad.as_object().unwrap() { + args[k] = v.clone(); + } + assert!(matches!(a.call("doc_print", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(!dir.join("refused.pdf").exists()); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn measurements_calibrate_draw_save_reopen_and_export() { let dir = workdir("measurements"); diff --git a/crates/print/README.md b/crates/print/README.md index 5db83312ca..e846243db5 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -13,7 +13,10 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() - **Size**: fit (to the sheet minus an 18 pt margin), actual size, shrink oversized, custom %; centred; auto orientation turns the sheet for landscape pages. - **Multiple**: 2/4/6/9/16 (or any n) pages per sheet, horizontal/vertical (reversed) order, - page borders, auto-rotation of pages that don't match the cell. + page borders, auto-rotation of pages that don't match the cell. With auto orientation a + predefined count turns the sheet to suit its grid (2 per sheet prints side by side on + landscape). A custom grid (`Layout::grid`, `doc_print` `cols`/`rows`, 1-99 each) is laid + out across and down the sheet as given and doesn't turn it: auto prints portrait. - **Cut and stack** (Multiple's page order): consecutive pages in each cell's pile, with aligned cut marks in the gutters. For example, 10 pages at 4 per sheet give `[1, 4, 7, 10]`, `[2, 5, 8, blank]`, `[3, 6, 9, blank]`. Print single-sided, diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 067112b2e7..8fffbdf649 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -37,7 +37,7 @@ pub use error::{PrintError, WORDINGS, Words, in_english}; use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ - Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, cups_media, media_option, }; #[cfg(test)] diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 0506eb5b18..ff860f5c65 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -69,6 +69,11 @@ pub enum Layout { order: PageOrder, border: bool, auto_rotate: bool, + /// A custom grid (the Print dialog's Custom pages per sheet): `cols` across and `rows` + /// down the sheet whatever its orientation, which is not picked for the grid, so Auto + /// prints portrait. A predefined count (`false`, [`Layout::multiple`]) is a grid on a + /// portrait sheet that turns to landscape when that suits it. + custom: bool, }, Booklet { subset: BookletSubset, @@ -107,10 +112,24 @@ impl Layout { (c, n.div_ceil(c).max(1)) } }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false } + } + + /// A custom grid of `cols` across by `rows` down, each 1 to [`MAX_GRID`]; every cell holds a + /// page. `None` for a count outside that. + pub fn grid(cols: usize, rows: usize) -> Option { + if !(1..=MAX_GRID).contains(&cols) || !(1..=MAX_GRID).contains(&rows) { + return None; + } + // At most 99 × 99, so this cannot overflow. + let per_sheet = cols.checked_mul(rows)?; + Some(Layout::Multiple { cols, rows, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: true }) } } +/// The most columns, or rows, a custom pages-per-sheet grid can have. +pub const MAX_GRID: usize = 99; + /// Acrobat's Comments & Forms choices. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Content { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 061a6ed5c3..fa34f5c6f8 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -164,8 +164,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P Layout::Label { flip } => { sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate, custom } => { + // A predefined count has at most 16 × 16 cells; a custom grid up to 99 each way. + let fits = if custom { cols <= crate::MAX_GRID && rows <= crate::MAX_GRID } else { cols.checked_mul(rows).is_some_and(|n| n <= 256) }; + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && fits); let Some(cells) = cells else { return Err(PrintError::Invalid("invalid number of pages per sheet".into())); }; @@ -177,7 +179,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P // A grid with more columns than rows wants a landscape sheet, and the reverse. let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { + let (gc, gr, sheet_landscape) = if custom { + // A custom grid is laid out as given, on the sheet as chosen (Auto: portrait). + (cols, rows, false) + } else if cols == rows { (cols, rows, page_landscape) } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index f51e505251..63d8a351e7 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -159,7 +159,10 @@ fn multiple_pages_per_sheet() { // Vertical order and borders. let v = layout( &sizes, - &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false }), + &settings( + vec![0, 1, 2], + Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false, custom: false }, + ), ) .unwrap(); assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]); @@ -183,10 +186,11 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pages(4), [vec![0, 1, 2, 3], vec![4, 5, 6]]); assert_eq!(pages(2), [vec![0, 1], vec![2, 3], vec![4, 5], vec![6]]); // Cut-stack piles take the requested count too. - let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false }; + let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false, custom: false }; assert_eq!(sheet_pages(&layout(&sizes, &settings((0..7).collect(), stacked)).unwrap()), [vec![0, 3, 6], vec![1, 4], vec![2, 5]]); // A count larger than the grid is capped by the grid; no pages per sheet is refused. - let grid = |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + let grid = + |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false }; assert_eq!(sheet_pages(&layout(&sizes[..3], &settings(vec![0, 1, 2], grid(9))).unwrap()), [vec![0, 1], vec![2]]); assert!(matches!(layout(&sizes, &settings(vec![0], grid(0))), Err(PrintError::Invalid(_)))); // The imposed PDF has one page per sheet. @@ -194,6 +198,34 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet() { + // Columns across and rows down whatever the sheet, which isn't turned for the grid (#6551). + let sizes = vec![(200.0, 300.0); 4]; + let at = |sheet: &Sheet, k: usize| (sheet.placed[k].matrix.0[4], sheet.placed[k].matrix.0[5]); + let grid = |cols, rows| Layout::grid(cols, rows).unwrap(); + for (orientation, size) in + [(Orientation::Auto, (612.0, 792.0)), (Orientation::Portrait, (612.0, 792.0)), (Orientation::Landscape, (792.0, 612.0))] + { + let s = |layout| Settings { orientation, ..settings((0..4).collect(), layout) }; + let across = layout(&sizes, &s(grid(2, 1))).unwrap(); + assert_eq!((across.len(), across[0].size), (2, size), "{orientation:?}"); + assert!(at(&across[0], 1).0 > at(&across[0], 0).0 && close(at(&across[0], 1).1, at(&across[0], 0).1), "side by side, {orientation:?}"); + let down = layout(&sizes, &s(grid(1, 2))).unwrap(); + assert_eq!(down[0].size, size, "{orientation:?}"); + assert!(close(at(&down[0], 1).0, at(&down[0], 0).0) && at(&down[0], 1).1 < at(&down[0], 0).1, "stacked, {orientation:?}"); + let three = layout(&sizes, &s(grid(3, 1))).unwrap(); + assert_eq!(sheet_pages(&three), [vec![0, 1, 2], vec![3]]); + } + // 1 to 99 each way, and a grid too fine for the paper is refused, not drawn. + assert!(Layout::grid(0, 1).is_none() && Layout::grid(1, 0).is_none() && Layout::grid(100, 1).is_none() && Layout::grid(1, usize::MAX).is_none()); + assert_eq!(layout(&sizes, &settings(vec![0], grid(17, 17))).unwrap()[0].placed.len(), 1); + assert!(matches!(layout(&sizes, &settings(vec![0], grid(99, 99))), Err(PrintError::Invalid(_)))); + let hostile = + Layout::Multiple { cols: 100, rows: 1, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: true }; + assert!(matches!(layout(&sizes, &settings(vec![0], hostile)), Err(PrintError::Invalid(_)))); +} + #[test] fn imposing_lists_the_pages_once() { let n = 150; @@ -450,7 +482,15 @@ fn driverless_printers_join_the_list_without_a_queue() { } fn cut_stack(cols: usize, rows: usize) -> Layout { - Layout::Multiple { cols, rows, per_sheet: cols.saturating_mul(rows), order: PageOrder::CutStack, border: false, auto_rotate: false } + Layout::Multiple { + cols, + rows, + per_sheet: cols.saturating_mul(rows), + order: PageOrder::CutStack, + border: false, + auto_rotate: false, + custom: false, + } } #[test] @@ -503,7 +543,7 @@ fn cutting_and_stacking_recovers_every_selected_page() { fn multiple_rejects_hostile_grids_without_panicking() { let sizes = [(200.0, 300.0)]; for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] { - let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false }; assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_)))); } let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) }; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index d5cdbf16ed..690dc3142e 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -337,15 +337,13 @@ pub(crate) fn body( }); } Handling::Multiple => { - ui.horizontal(|ui| { - ui.label(tl!("Pages per sheet:")); - combo(ui, "per-sheet", tl!("Pages per sheet:"), &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); + let page_order = |ui: &mut egui::Ui, order: &mut PageOrder| { ui.label(tl!("Page order:")); combo( ui, "order", tl!("Page order:"), - &mut d.order, + order, &[ (PageOrder::Horizontal, tl!("Horizontal")), (PageOrder::HorizontalReversed, tl!("Horizontal reversed")), @@ -355,7 +353,34 @@ pub(crate) fn body( ], 150.0, ); + }; + ui.horizontal(|ui| { + ui.label(tl!("Pages per sheet:")); + // `None` is Custom: columns by rows, typed in. + let mut per_sheet = (!d.custom_grid).then_some(d.per_sheet); + combo( + ui, + "per-sheet", + tl!("Pages per sheet:"), + &mut per_sheet, + &[(Some(2), "2"), (Some(4), "4"), (Some(6), "6"), (Some(9), "9"), (Some(16), "16"), (None, tl!("Custom"))], + 70.0, + ); + d.custom_grid = per_sheet.is_none(); + if let Some(n) = per_sheet { + d.per_sheet = n; + page_order(ui, &mut d.order); + } else { + let max = print::MAX_GRID; + ui.add(egui::DragValue::new(&mut d.cols).range(1..=max)).accessible_name(tl!("Columns")); + ui.label(tl!("by")); + ui.add(egui::DragValue::new(&mut d.rows).range(1..=max)).accessible_name(tl!("Rows")); + } }); + if d.custom_grid { + // The grid's fields take the room the page order had. + ui.horizontal(|ui| page_order(ui, &mut d.order)); + } ui.horizontal(|ui| { ui.add(widgets::checkbox(&mut d.border, tl!("Print page border"))); ui.add(widgets::checkbox(&mut d.auto_rotate, tl!("Auto-rotate pages"))); diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index be2a22efb3..27080a7329 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -45,6 +45,10 @@ pub struct PrintDraft { pub size: SizeMode, pub custom_scale: f64, pub per_sheet: usize, + /// Pages per sheet: Custom, `cols` across by `rows` down instead of `per_sheet`. + pub custom_grid: bool, + pub cols: usize, + pub rows: usize, pub order: PageOrder, pub border: bool, pub auto_rotate: bool, @@ -106,6 +110,9 @@ impl Default for PrintDraft { size: SizeMode::Fit, custom_scale: 100.0, per_sheet: 2, + custom_grid: false, + cols: 2, + rows: 1, order: PageOrder::Horizontal, border: false, auto_rotate: true, @@ -180,12 +187,19 @@ impl PrintDraft { SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale), m => m, }), - Handling::Multiple => match Layout::multiple(self.per_sheet) { - Layout::Multiple { cols, rows, per_sheet, .. } => { - Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate } + Handling::Multiple => { + let grid = if self.custom_grid { + Layout::grid(self.cols, self.rows).ok_or_else(|| tl!("Columns and rows must each be 1 to 99.").to_string())? + } else { + Layout::multiple(self.per_sheet) + }; + match grid { + Layout::Multiple { cols, rows, per_sheet, custom, .. } => { + Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate, custom } + } + other => other, } - other => other, - }, + } Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding }, Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks }, Handling::Label => Layout::Label { flip: self.label_flip }, diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 81025e756f..3e0fec7a6e 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -215,6 +215,38 @@ fn cut_stack_dialog_previews_saves_and_refuses_duplex() { let _ = std::fs::remove_file(out); } +#[test] +fn custom_pages_per_sheet_takes_columns_by_rows() { + use pdfcraft_engine::print::{Layout, PageOrder}; + let mut h = harness(); + let bytes = h.state().session.create_blank(200.0, 300.0, 4).unwrap(); + h.state_mut().open_bytes("narrow.pdf", None, bytes.as_ref().clone()).unwrap(); + h.state_mut().execute("print.dialog"); + h.run_steps(3); + h.get_by_label("Multiple").click(); + h.run_steps(2); + assert!(h.query_by_label("Columns").is_none(), "a predefined count has no grid fields"); + h.get_by_role_and_label(Role::ComboBox, "Pages per sheet:").click(); + h.run_steps(2); + h.get_by_label("Custom").click(); + h.run_steps(2); + // Two number fields, columns by rows, with 2 by 1 to start. + assert_eq!(h.get_by_label("Columns").value().as_deref(), Some("2")); + assert_eq!(h.get_by_label("Rows").value().as_deref(), Some("1")); + h.get_by_label("by"); + let d = &h.state().print_draft; + assert!(d.custom_grid); + let layout = d.settings(4, &[]).unwrap().layout; + assert_eq!( + layout, + Layout::Multiple { cols: 2, rows: 1, per_sheet: 2, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: true } + ); + h.get_by_label("Sheet 1 of 2"); + // A count outside 1 to 99 (set through the control channel) is explained, not drawn. + h.state_mut().print_draft.rows = 0; + assert!(h.state().print_draft.settings(4, &[]).is_err()); +} + #[test] fn a_failed_save_as_pdf_keeps_the_dialog_open() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6604bafd4e..8b6b31849f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8273,8 +8273,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"] -notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." +evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex", "crates/print/src/tests.rs::a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet", "crates/automation/tests/it/automation.rs::custom_pages_per_sheet_prints_the_grid_as_given", "crates/ui-egui/tests/it/print.rs::custom_pages_per_sheet_takes_columns_by_rows"] +notes = "2/4/6/9/16 (the sheet turns to suit the grid with Auto orientation) or Custom columns by rows, 1-99 each, laid out across and down the sheet as oriented (Auto prints portrait; doc_print cols/rows); four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." [[feature]] id = "print.booklet" From b9ffb68b12399eb93a3c8177fbc5cf92da7a334e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:57:28 +0800 Subject: [PATCH 23/29] M6: a product of empty fields is 0, not 1 (form.af-simple-calculate) (#6567) --- crates/forms/src/af/calc.rs | 4 +++ crates/forms/src/af/tests.rs | 29 ++++++++++++++++++++ crates/forms/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index d29956cbc2..5c8098dfbb 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< let num = |s: &String| make_number(s).unwrap_or(0.0); match c { Calculate::None => None, + // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the + // empty product 1: a blank quantity × price row reads 0, and a total over the rows + // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) + Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()), Calculate::Simple { op, fields } => { let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); let v = match op { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 79c5c7be02..5e8c4f085f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -360,6 +360,35 @@ fn dates_times_and_masks() { assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); } +#[test] +fn a_calculation_over_fields_without_values_is_0_for_every_operation() { + // A blank row: neither field has a value, so the form's lookup returns none. + let vals = |name: &str| -> Vec { if name == "a" { vec!["2".into()] } else { vec![] } }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + for op in CalcOp::ALL { + assert_eq!(simple(op, &["qty", "price"]), Some("0".into()), "{op:?} of empty fields"); + assert_eq!(simple(op, &[]), Some("0".into()), "{op:?} of no fields"); + } + assert_eq!(simple(CalcOp::Product, &["a", "qty"]), Some("2".into()), "a field with no value is left out"); +} + +/// A drop-down's blank option is often a single space, the value it starts with. That is a value +/// worth 0, so a row whose quantity is still blank reads 0 × price = 0. Read as "no value", it +/// would be left out and the row would read the price alone. +#[test] +fn a_blank_option_of_a_single_space_is_a_value_worth_0() { + let vals = |name: &str| -> Vec { + match name { + "qty" => vec![" ".into()], + "price" => vec!["605".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Product, &["qty", "price"]), Some("0".into())); + assert_eq!(simple(CalcOp::Product, &["qty", "none"]), Some("0".into()), "a blank quantity on a row without a price"); +} + #[test] fn validation_and_calculation() { let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a96a519d8c..f252927f05 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1943,6 +1943,59 @@ fn missing_fields_and_built_names_are_flagged() { assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); } +/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank +/// option, and starting value, is a single space, a prefilled price, the row's product and a total +/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row +/// without a price leaves the product nothing to multiply, and it is 0 too, not 1. +#[test] +fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20) + }; + let qty = |name: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>", + y + 20 + ) + }; + let prd = |row: u32| { + format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {row}\"\\)\\);) >> >>") + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + qty("QTY 0", 700), + tx("price 0", 700, "/V (605)"), + tx("Total 0", 700, &format!("/V (0) {}", prd(0))), + qty("QTY 1", 660), + tx("price 1", 660, "/V (780)"), + tx("Total 1", 660, &format!("/V (0) {}", prd(1))), + qty("QTY 2", 620), + tx("price 2", 620, ""), + tx("Total 2", 620, &format!("/V (0) {}", prd(2))), + tx( + "Total", + 580, + r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#, + ), + ]; + let mut doc = document(&objs); + let totals = |doc: &Document| { + let all = fields(doc); + ["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default()) + }; + recalculate(&mut doc).unwrap(); + assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0"); + set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row"); + set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price"); + let doc = reopen(&doc); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b6b31849f..7d3249ddb7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4770,8 +4770,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af/tests.rs::a_calculation_over_fields_without_values_is_0_for_every_operation", "crates/forms/src/af/tests.rs::a_blank_option_of_a_single_space_is_a_value_worth_0", "crates/forms/src/tests.rs::an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows"] +notes = "SUM, PRD, AVG, MIN, MAX over fields and groups; fields without a value are left out, and when none of the listed fields has one, every operation gives 0 (a product too, not 1); a drop-down's blank option of a single space is a value worth 0" [[feature]] id = "form.simplified-notation" From 83a6a8394e27560b3b7ae3f7faf5c2252e1444d5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:57:56 +0800 Subject: [PATCH 24/29] M7: Edit text lines set in a Type 3 font are as high as their glyphs (edit.text-edit) (#6571) --- crates/automation/tests/it/automation.rs | 43 +++++ crates/edit/src/tests.rs | 39 ++++ crates/fonts/src/pdf.rs | 127 ++++++++++++- crates/fonts/src/pdf/tests.rs | 222 +++++++++++++++++++++++ 4 files changed, 422 insertions(+), 9 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index d68cd87f51..6e05d752b7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -12165,6 +12165,49 @@ fn comments_copy_and_paste_between_documents_through_tools() { assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); } +/// A bitmap Type 3 font drawn with a flipped font matrix under a flipped text matrix (as TeX's +/// bitmap fonts are): `text_lines` boxes a line by its glyphs' `d1` boxes (ISO 32000-2 §9.6.4), +/// as high as the ink, not a 0.09-point sliver. +#[test] +fn text_lines_box_a_flipped_type3_line_by_its_glyphs() { + let content = "BT /F1 1 Tf 0.15 0 0 -0.15 10 20 Tm (ab) Tj ET"; + let glyph_a = "46 0 3 -1 42 57 d1 3 -1 39 58 re f"; + let glyph_b = "42 0 2 -18 40 38 d1 2 -18 38 56 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 50] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type3 /FontMatrix [1 0 0 -1 0 0] /FontBBox [-4 -18 51 57] /FirstChar 97 /LastChar 98 \ + /Widths [46 42] /Encoding << /Differences [97 /a /b] >> /CharProcs << /a 6 0 R /b 7 0 R >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()), + format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("type3-text-lines"); + std::fs::write(dir.join("type3.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "type3.pdf" }))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + // Glyph space y -18..57, turned over twice: 57 × 0.15 above the baseline at y 20 (8.55) and + // 18 × 0.15 below it (2.7). Top-left origin on a 50-point page: 21.45 down to 32.7. + assert_eq!(lines["count"], 1, "{lines}"); + assert_eq!(lines["lines"][0]["text"], "ab"); + assert_eq!(lines["lines"][0]["rect"], json!([10.0, 21.45, 23.2, 32.7]), "{lines}"); +} + #[test] fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a7b652a681..ba80058c45 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1521,6 +1521,45 @@ fn text_page(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +/// ISO 32000-2 §9.6.4: an Edit-text line set in a Type 3 bitmap font is as high as the boxes its +/// glyphs declare with `d1`, whatever its `/FontBBox` says and whichever way its font matrix +/// turns glyph space, so two lines 8 points apart are two lines with 7-point boxes, not slivers. +#[test] +fn edit_lines_in_a_type3_font_are_as_high_as_their_glyphs() { + // Glyph `a` is drawn 60 units wide, from 20 below the baseline to 50 above it, at 0.1 Tf: + // 7 points high, 2 below the baseline. + for (matrix, flip) in [("1 0 0 1 0 0", 1), ("1 0 0 -1 0 0", -1)] { + for bbox in ["[0 0 0 0]", "[0 0 1 1]"] { + let mut doc = text_page(&format!("BT /T3 0.1 Tf 1 0 0 {flip} 72 700 Tm (aa) Tj 1 0 0 {flip} 72 692 Tm (aa) Tj ET")); + let glyph = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"60 0 0 -20 60 50 d1 0 -20 60 70 re f".to_vec()))); + let font = doc.add( + pdfcraft_cos::parse_indirect( + format!( + "1 0 obj << /Type /Font /Subtype /Type3 /FontMatrix [{matrix}] /FontBBox {bbox} /FirstChar 97 /LastChar 97 /Widths [60] /Encoding << /Differences [97 /a] >> /CharProcs << /a {} 0 R >> >> endobj", + glyph.num + ) + .as_bytes(), + 0, + &|_| None, + ) + .unwrap() + .1, + ); + let p = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(p, |d| { + d.set( + b"Resources".to_vec(), + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(font))])))])), + ) + }) + .unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let rects: Vec<_> = lines.iter().map(|l| (l.text.as_str(), l.rect.map(|v| (v * 100.0).round() / 100.0))).collect(); + assert_eq!(rects, [("aa", [72.0, 698.0, 84.0, 705.0]), ("aa", [72.0, 690.0, 84.0, 697.0])], "{matrix} {bbox}"); + } + } +} + #[test] fn tracked_text_keeps_words_in_paragraphs_and_objects() { let expected = "TRACKED SAMPLE TEXT"; diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index a0406754c8..6373d6427f 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -356,6 +356,94 @@ const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger /// one is not decoded at all. const MAX_CMAP_BYTES: usize = 16 << 20; +/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. +const MAX_TYPE3_GLYPHS: usize = 256; +/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. +const MAX_TYPE3_GLYPH_BYTES: usize = 64 << 10; +/// Decoded bytes all of one font's glyph descriptions may take together. +const MAX_TYPE3_FONT_BYTES: usize = 4 << 20; +/// Farthest a Type 3 glyph box reaches from the baseline, in text space per unit of font size. A +/// bitmap font drawn in page units at `0.01 Tf` is a few thousand; past this a box is garbage, and +/// clamping it keeps the run boxes built from it finite. +const MAX_TYPE3_EXTENT: f64 = 1e6; + +/// The union of the boxes a Type 3 font's glyph descriptions declare with `d1` (ISO 32000-2 +/// §9.6.4, Table 111), in glyph space, and whether every description chosen was read; `None` when +/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` +/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a +/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] +/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. +fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { + let procs = dict(doc, font.get(b"CharProcs"))?; + let mut names: Vec<&[u8]> = Vec::new(); + // Whether every glyph that can be drawn is read; if not, the union may be short of them. + let mut complete = true; + let encoding = dict(doc, font.get(b"Encoding")); + // A base encoding reaches glyphs `/Differences` doesn't name, which Type 3 doesn't provide for (its + // `/Differences` "shall specify the complete character encoding", Table 110): those boxes go unread. + if encoding.as_ref().is_some_and(|e| e.contains(b"BaseEncoding")) { + complete = false; + } + let diffs = encoding.as_ref().and_then(|e| e.get(b"Differences")).map(|d| doc.resolve(d)); + for name in diffs.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_name) { + if procs.get(name).is_none() || names.contains(&name) { + continue; + } + if names.len() >= MAX_TYPE3_GLYPHS { + complete = false; + break; + } + names.push(name); + } + let chosen: Vec<&Object> = if names.is_empty() { + complete = procs.len() <= MAX_TYPE3_GLYPHS; + procs.iter().take(MAX_TYPE3_GLYPHS).map(|(_, proc)| proc).collect() + } else { + names.iter().filter_map(|n| procs.get(n)).collect() + }; + let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + let mut union: Option<[f64; 4]> = None; + for proc in chosen { + let Object::Stream(s) = &*doc.resolve(proc) else { continue }; + let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { + if budget.remaining() == 0 { + // The rest go unread too. + complete = false; + break; + } + continue; + }; + if let Some([llx, lly, urx, ury]) = d1_box(&data) { + let r = [llx.min(urx), lly.min(ury), llx.max(urx), lly.max(ury)]; + union = Some(union.map_or(r, |u| [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])])); + } + } + union.map(|u| (u, complete)) +} + +/// The glyph box `llx lly urx ury` of a glyph description that begins with `wx wy llx lly urx ury +/// d1`; `None` for `d0` or anything else first. +fn d1_box(data: &[u8]) -> Option<[f64; 4]> { + let mut lexer = pdfcraft_cos::Lexer::new(data, 0); + let mut operands = [0.0; 6]; + for slot in &mut operands { + lexer.skip_ws(); + *slot = std::str::from_utf8(lexer.token()).ok()?.parse::().ok().filter(|v| v.is_finite())?; + } + lexer.skip_ws(); + (lexer.token() == b"d1").then(|| [operands[2], operands[3], operands[4], operands[5]]) +} + +/// The lowest and highest text-space y of the glyph-space box `r` under a font matrix whose +/// y column is `b`, `d`, `f` (§9.2.4): a flipped matrix (`d < 0`) turns the box over. `None` +/// when the box has no height in text space or the numbers overflow; clamped to +/// [`MAX_TYPE3_EXTENT`]. +fn text_y_extent(r: [f64; 4], b: f64, d: f64, f: f64) -> Option<(f64, f64)> { + let ys = [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].map(|(x, y)| b * x + d * y + f); + let lo = ys.iter().copied().fold(f64::INFINITY, f64::min); + let hi = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max); + (lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo.max(-MAX_TYPE3_EXTENT), hi.min(MAX_TYPE3_EXTENT))) +} /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -754,6 +842,8 @@ impl Metrics { m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); let descriptor; + // A Type 3 font measured from its glyph descriptions' `d1` boxes or its `/FontBBox` (see below). + let mut type3_measured = false; if subtype == b"Type0" { m.composite = true; m.default = 1000.0; @@ -814,13 +904,28 @@ impl Metrics { descriptor = dict(doc, font.get(b"FontDescriptor")); m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0); if subtype == b"Type3" { - let fm = nums(doc, font.get(b"FontMatrix")); - if fm.len() == 6 { - m.scale = fm[0]; - let bbox = nums(doc, font.get(b"FontBBox")); - if bbox.len() == 4 && fm[3] != 0.0 { - m.ascent = (bbox[3] * fm[3]).max(0.5); - m.descent = (bbox[1] * fm[3]).min(-0.1); + if let [a, b, _, d, _, f] = nums(doc, font.get(b"FontMatrix"))[..] { + m.scale = a; + // The glyphs' own boxes first: a bitmap font's `/FontBBox` is often all zero + // or one unit high, while each `d1` declares the box its glyph fills. + // Each `d1` box "shall be correct" (Table 111), so their union is used as + // declared, only widened to reach the baseline: a font of small glyphs gets a + // small box. A `/FontBBox` is often one unit high, so it keeps the 0.5 / 0.1 em + // minimum. + // A union cut short by a limit may miss glyphs that are drawn, so a nonzero + // `/FontBBox` comes first then; all zero, the partial union is still better + // than no box. A box with no height in text space gives way to the next. + let glyphs = type3_glyph_box(doc, font); + // All zero: no assumption about glyph sizes (Table 110). + let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); + let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); + let candidates = [union(true), bbox.map(|r| (r, 0.5, -0.1)), union(false)]; + if let Some(((lo, hi), min_ascent, max_descent)) = + candidates.into_iter().flatten().find_map(|(r, up, down)| text_y_extent(r, b, d, f).map(|e| (e, up, down))) + { + m.ascent = hi.max(min_ascent); + m.descent = lo.min(max_descent); + type3_measured = true; } } } else if m.widths.is_empty() { @@ -840,11 +945,15 @@ impl Metrics { let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()); let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()); // Fonts often claim 0; never shrink the glyph box below a sensible minimum. - if ascent.is_some() || subtype != b"Type3" { + // A Type 3 font measured from its glyphs' `d1` boxes or its `/FontBBox` keeps those: + // they are in glyph space and go through its font matrix, while a descriptor's + // /Ascent and /Descent are optional for Type 3 (Table 120) and read here as 1000-unit + // values, whatever the matrix. + if (ascent.is_some() && !type3_measured) || subtype != b"Type3" { let a = ascent.unwrap_or(0.0) / 1000.0; m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 }; } - if descent.is_some() || subtype != b"Type3" { + if (descent.is_some() && !type3_measured) || subtype != b"Type3" { let de = descent.unwrap_or(0.0) / 1000.0; m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 }; } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 67cdcd3a23..d1b96fe3d0 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -265,6 +265,228 @@ fn type3_descriptor_names_the_font_without_changing_its_metrics() { assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); } +/// A Type 3 font with `matrix`, `bbox` and one glyph description per entry of `procs`. +fn type3(doc: &mut Document, matrix: [f64; 6], bbox: [i64; 4], procs: &[&str]) -> Dict { + let mut charprocs = Dict::new(); + for (i, proc) in procs.iter().enumerate() { + let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), proc.as_bytes()))); + charprocs.set(format!("g{i}").into_bytes(), Object::Ref(glyph)); + } + font( + doc, + vec![ + ("Subtype", Object::name("Type3")), + ("FontMatrix", Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect())), + ("FontBBox", Object::Array(bbox.iter().map(|&v| Object::Int(v)).collect())), + ("CharProcs", Object::Dict(charprocs)), + ], + ) +} + +/// ISO 32000-2 §9.6.4: a bitmap font whose `/FontBBox` is all zero (no assumption about glyph +/// sizes) is as high as the boxes its glyphs declare with `d1`, not a fixed 0.6 of the size. +#[test] +fn a_type3_font_is_as_high_as_its_d1_glyph_boxes() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let f = type3(&mut doc, unit, [0, 0, 0, 0], &["100 0 0 0 100 100 d1\nq 100 0 0 100 0 0 cm BI /W 1 /H 1 /IM true ID \x00 EI Q"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (100.0, 0.0)); + // Shown at `0.12 Tf`, the glyph is 12 points high. + assert!((m.ascent * 0.12 - 12.0).abs() < 1e-9); + + // The union of every glyph's box, leading comments and white space allowed; `d0` glyphs + // declare none, and a non-numeric operand isn't a box. + let f = type3(&mut doc, unit, [0, 0, 0, 0], &["% c\n 50 0 0 -20 40 60 d1", "60 0 d0", "60 0 0 -30 40 x d1", "60 0 0 0 40 80 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (80.0, -20.0)); + + // A wrong but non-zero `/FontBBox` (one unit high) gives way to the glyphs. + let f = type3(&mut doc, unit, [0, 0, 1, 1], &["100 0 0 -10 100 90 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (90.0, -10.0)); + + // No `d1` anywhere: the `/FontBBox` still decides, scaled by the matrix. + let f = type3(&mut doc, [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], [0, -300, 1000, 900], &["600 0 d0"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 0.9).abs() < 1e-12 && (m.descent + 0.3).abs() < 1e-12, "{} {}", m.ascent, m.descent); +} + +/// Small glyphs keep their small box: there is no 0.5 em / 0.1 em minimum for `d1` boxes, which +/// made a 58-point line of 0.15 em glyphs 35 points high. The shape of a scanned-text bitmap +/// font: matrix 0.0042, `/FontBBox` all zero, `d1` boxes from -8 to 33 units. +#[test] +fn small_type3_glyphs_keep_their_d1_height() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1", "26 0 2 -8 24 28 d1", "13 0 0 0 1 1 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent); + // Glyphs that all sit above the baseline still reach down to it. + let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && m.descent == 0.0, "{} {}", m.ascent, m.descent); +} + +/// A font matrix that turns glyph space over (`[1 0 0 -1 0 0]`, drawn under a flipped text +/// matrix) puts the top of the glyph box at text-space y -top: the box is as high as before, +/// upside down, not clamped to a sliver. +#[test] +fn a_flipped_type3_font_matrix_keeps_the_glyph_height() { + let mut doc = Document::new_empty(); + let flipped = [1.0, 0.0, 0.0, -1.0, 0.0, 0.0]; + let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &["37 0 2 -1 34 38 d1", "42 0 2 -18 40 38 d1", "46 0 3 -1 42 57 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (18.0, -57.0)); + let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &[]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (18.0, -57.0), "the /FontBBox, turned over the same way"); +} + +/// Glyph descriptions are untrusted: unreadable ones, a box of infinities, a degenerate matrix +/// and a font with more glyphs than are read all leave finite metrics. +#[test] +fn type3_glyph_boxes_are_read_within_bounds() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + for procs in [&["inf 0 0 -inf 1 inf d1"][..], &["d1"], &["1 2 3 d1"], &[""], &["1 0 0 0 1 0 d1"], &["1e308 0 -1e308 -1e308 1e308 1e308 d1"]] { + let f = type3(&mut doc, unit, [0, 0, 0, 0], procs); + let m = Metrics::from_dict(&doc, &f); + assert!(m.ascent > 0.0 && m.descent < 0.0, "{procs:?}"); + assert!(m.ascent <= MAX_TYPE3_EXTENT && m.descent >= -MAX_TYPE3_EXTENT, "{procs:?}"); + } + // A matrix with no y scale gives the box no height: the fallback metrics stand. + let f = type3(&mut doc, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0, 0, 0, 0], &["1 0 0 0 1 100 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (Metrics::fallback().ascent, Metrics::fallback().descent)); + // Glyphs past the first MAX_TYPE3_GLYPHS are not read. + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS]; + procs.push("1 0 0 0 1 1000 d1"); + let f = type3(&mut doc, unit, [0, 0, 0, 0], &procs); + assert_eq!(Metrics::from_dict(&doc, &f).ascent, 10.0); +} + +/// `/Differences` with the glyph names `names` from code 0, set on the Type 3 font `f`. +fn with_differences(f: &mut Dict, names: &[String]) { + let mut items = vec![Object::Int(0)]; + items.extend(names.iter().map(|n| Object::name(n))); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(items)); + f.set(b"Encoding".to_vec(), Object::Dict(enc)); +} + +/// The glyph descriptions the encoding reaches are the ones read, since its `/Differences` +/// "shall specify the complete character encoding" (ISO 32000-2 Table 110): a drawn glyph past +/// the first [`MAX_TYPE3_GLYPHS`] descriptions still counts. +#[test] +fn type3_glyph_boxes_are_read_for_the_glyphs_the_encoding_reaches() { + let mut doc = Document::new_empty(); + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS + 44]; + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + with_differences(&mut f, &[format!("g{}", MAX_TYPE3_GLYPHS + 44), "g0".into()]); + assert_eq!(Metrics::from_dict(&doc, &f).ascent, 1000.0); +} + +/// Byte limits on glyph descriptions: one larger than [`MAX_TYPE3_GLYPH_BYTES`] is skipped, its +/// `d1` box unread, and the rest are read; once the font's [`MAX_TYPE3_FONT_BYTES`] run out, +/// the boxes read so far may miss glyphs that are drawn, so a nonzero `/FontBBox` stands. +#[test] +fn type3_glyph_byte_limits_leave_honest_metrics() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let pad = |head: &str, len: usize| format!("{head}{}", " ".repeat(len.saturating_sub(head.len()))); + let oversized = pad("1 0 0 0 1 1000 d1", MAX_TYPE3_GLYPH_BYTES + 100); + let procs = [oversized.as_str(), "1 0 0 0 1 3 d1"]; + let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs); + with_differences(&mut f, &["g0".into(), "g1".into()]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (3.0, 0.0), "the oversized glyph is skipped, the other read"); + + let filler = pad("0 0 m", MAX_TYPE3_GLYPH_BYTES - 1000); + let fillers = MAX_TYPE3_FONT_BYTES / (MAX_TYPE3_GLYPH_BYTES - 1000) + 1; + let mut procs = vec!["1 0 0 0 1 1 d1"]; + procs.extend(std::iter::repeat_n(filler.as_str(), fillers)); + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs); + let names: Vec = (0..procs.len()).map(|i| format!("g{i}")).collect(); + with_differences(&mut f, &names); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); +} + +/// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` +/// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). +#[test] +fn type3_d1_glyph_boxes_win_over_descriptor_metrics() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let mut f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 -8 9 33 d1"]); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"Ascent".to_vec(), Object::Int(891)); + descriptor.set(b"Descent".to_vec(), Object::Int(-216)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent); +} + +/// A `/Differences` naming more distinct glyphs than [`MAX_TYPE3_GLYPHS`] leaves some unread, so +/// a nonzero `/FontBBox` stands over the boxes of those read. +#[test] +fn type3_glyphs_past_the_limit_leave_the_font_bbox_standing() { + let mut doc = Document::new_empty(); + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS]; + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &procs); + let names: Vec = (0..procs.len()).map(|i| format!("g{i}")).collect(); + with_differences(&mut f, &names); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + +/// `d1` boxes with no height in text space (all flat) give way to a usable `/FontBBox`. +#[test] +fn flat_type3_glyph_boxes_give_way_to_the_font_bbox() { + let mut doc = Document::new_empty(); + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 5 1 5 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + +/// A Type 3 font measured from its `/FontBBox` keeps that box through its font matrix over a +/// descriptor's `/Ascent` and `/Descent`, which would be read as 1000-unit values. +#[test] +fn a_type3_font_bbox_wins_over_descriptor_metrics() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let mut f = type3(&mut doc, small, [0, -50, 100, 200], &[]); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"Ascent".to_vec(), Object::Int(891)); + descriptor.set(b"Descent".to_vec(), Object::Int(-216)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let m = Metrics::from_dict(&doc, &f); + // 200 and -50 units at 0.0042, kept at the 0.5 / -0.1 em floor. + assert!((m.ascent - 0.84).abs() < 1e-9 && (m.descent + 0.21).abs() < 1e-9, "{} {}", m.ascent, m.descent); +} + +/// An `/Encoding` with a `/BaseEncoding` reaches glyphs its `/Differences` doesn't name, whose +/// boxes go unread, so a nonzero `/FontBBox` stands over the boxes of those named. +#[test] +fn a_type3_base_encoding_leaves_the_font_bbox_standing() { + let mut doc = Document::new_empty(); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 0 1 10 d1", "1 0 0 0 1 1000 d1"]); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0), Object::name("g0")])); + f.set(b"Encoding".to_vec(), Object::Dict(enc)); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + #[test] fn simple_fonts_decode_and_encode_through_their_encoding() { let mut doc = Document::new_empty(); From a2f155e4dfe23a981c8fdc71c63e44a9a10f97e8 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Mon, 9 Nov 2026 02:58:19 +0200 Subject: [PATCH 25/29] Deduplicate pages in ocr_job and use a set for page lookup (#6586) --- crates/engine/src/ocr.rs | 70 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index cb31e3b550..512c1c97ad 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -971,10 +971,23 @@ impl Session { pub fn ocr_job(&self, id: DocId, pages: &[usize], settings: OcrSettings) -> Option { let doc = self.get(id)?; let all = doc.info.pages.len(); - let pages: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; + let mut seen = vec![false; all]; + let pages: Vec = if pages.is_empty() { + seen.fill(true); + (0..all).collect() + } else { + let mut deduped = Vec::new(); + for &p in pages { + if seen.get(p) == Some(&false) { + seen[p] = true; + deduped.push(p); + } + } + deduped + }; let native = (0..all) .map(|p| { - if !pages.contains(&p) { + if seen.get(p) != Some(&true) { return None; } doc.page_images(p) @@ -2234,6 +2247,59 @@ mod tests { assert_eq!(page, 0); } + /// A scan selected explicitly is rendered at its native scale, like the same page in an + /// all-pages job: selecting pages must not lose the native-resolution lookup and render + /// the scan above its own resolution at a higher chosen dpi. + #[test] + fn selected_scan_page_uses_native_scale_like_all_pages() { + let mut s = Session::new(); + let page = export::encode_png(2480, 3508, &vec![255; (2480 * 3508 * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page)]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let settings = OcrSettings { dpi: 300.0, ..Default::default() }; + let all = s.ocr_job(id, &[], settings.clone()).unwrap(); + let selected = s.ocr_job(id, &[0], settings).unwrap(); + assert_eq!(selected.pages, vec![0]); + let wanted = 300.0 / 72.0; + assert!(all.render_scale(0) < wanted, "native resolution limits the all-pages job: {}", all.render_scale(0)); + assert!( + (selected.render_scale(0) - all.render_scale(0)).abs() < 1e-4, + "selected page {} vs all-pages {}", + selected.render_scale(0), + all.render_scale(0) + ); + } + + /// A job requested with the same page twice carries each page once: a duplicate would + /// otherwise be recognized twice. + #[test] + fn ocr_job_deduplicates_requested_pages() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let job = s.ocr_job(id, &[2, 0, 2, 1, 0, 2], OcrSettings::default()).unwrap(); + assert_eq!(job.pages, vec![2, 0, 1], "each requested page appears once, in request order"); + } + + #[test] + fn ocr_job_bounds_page_vector_reservation_by_page_count() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let requested = vec![999usize; 1024]; + let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap(); + assert!(job.pages.is_empty()); + assert!(job.pages.capacity() <= 3, "reservation {} exceeds the 3 pages that can survive deduplication", job.pages.capacity()); + } + + #[test] + fn ocr_job_does_not_reserve_capacity_for_invalid_pages() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(1024)), None).unwrap(); + let requested = vec![4096usize; 1024]; + let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap(); + assert!(job.pages.is_empty()); + assert_eq!(job.pages.capacity(), 0, "no valid pages were found, so no capacity should be reserved"); + } + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution /// letter page is still never shrunk below one pixel per point. From 87bc00a99187f661d50fa1599cf0c487bfea3935 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:58:34 +0800 Subject: [PATCH 26/29] M3: Find matches words divided by soft hyphens, zero width characters or a Chinese or Japanese line end (view.find) (#6595) --- crates/render/src/text.rs | 4 +- crates/render/src/text/search.rs | 138 +++++++- crates/render/tests/it/search.rs | 526 ++++++++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 4 files changed, 656 insertions(+), 16 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index aa66a4b683..bc132466b1 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -131,7 +131,9 @@ impl PageText { } /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks, - /// which count as a single space). + /// which count as a single space, or as nothing where the line runs on between two Chinese or + /// Japanese characters or ends in a soft hyphen: there a query with a space across the break + /// doesn't match, and one without does). pub fn find(&self, needle: &str) -> Vec> { self.find_opts(needle, false, false) } diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index c68660dbe9..71670cc271 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -1,4 +1,8 @@ //! Literal search in normalized page text, retaining the original glyph owners. +//! +//! Words and lines are separated by one space, except where a line of Chinese or Japanese runs on +//! to the next: a break between two such characters is no word break, so it reads as nothing and +//! "検知機構" is found when the line ends after "検知". use super::{PageText, fold_case}; use std::ops::Range; @@ -7,21 +11,51 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b return Vec::new(); } let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; - let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect(); + let needle: Vec = query.split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); } let mut chars: Vec<(char, usize)> = Vec::new(); + // Whether a skipped ZERO WIDTH SPACE came right before each character: it draws nothing, but + // it is a word boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words + // with no visible gap, so whole-word search still sees two words there. + let mut zero_width_space: Vec = Vec::new(); + let mut pending_zero_width_space = false; + // The last character pushed other than a variation selector, kept as characters are pushed so + // a long run of selectors costs no rescan. + let mut last_base: Option = None; for (i, glyph) in page.glyphs.iter().enumerate() { + let text = fold(&glyph.text); let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b); - let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break; + // A soft hyphen marks where a word may be divided at a line end (ISO 32000-2 §14.8.2.3), so + // a line that ends in one runs on to the next with no word break between them. + // Other invisible characters after the soft hyphen don't hide it. + let soft_break = line_break && last_kept(page, i) == Some('\u{AD}'); + // So does a line of Chinese or Japanese that runs on to the next. The characters on both + // sides of the break are the visible ones: invisible characters are skipped below, and a + // variation selector after the last one only picks its glyph. + let joined = line_break && last_base.is_some_and(unspaced) && first_visible(page, i).is_some_and(unspaced) && wraps(page, i); + let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break)); if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { chars.push((' ', i)); + last_base = Some(' '); + zero_width_space.push(false); + pending_zero_width_space = false; } - for c in fold(&glyph.text).chars() { + for c in text.chars() { + if is_invisible(c) { + pending_zero_width_space |= c == '\u{200B}'; + continue; + } let c = if c.is_whitespace() { ' ' } else { c }; if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { chars.push((c, i)); + if !is_variation_selector(c) { + last_base = Some(c); + } + zero_width_space.push(pending_zero_width_space && c != ' '); + pending_zero_width_space = false; } } } @@ -48,7 +82,9 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } let end = i.saturating_add(1); let start = end.saturating_sub(needle.len()); - let bounded = !whole_words || (!word(start.checked_sub(1)) && !word(Some(end))); + // A skipped zero width space before the first character or after the last is a boundary. + let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false); + let bounded = !whole_words || ((after_break(start) || !word(start.checked_sub(1))) && (after_break(end) || !word(Some(end)))); if bounded { if let Some((_, first)) = chars.get(start) && let Some(last) = owner.checked_add(1) @@ -68,6 +104,100 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b hits } +/// A character that draws nothing inside a word and only tells layout where a line may break or +/// letters may join: SOFT HYPHEN, ZERO WIDTH SPACE, ZERO WIDTH NON-JOINER and JOINER, WORD JOINER +/// and ZERO WIDTH NO-BREAK SPACE. All six are Unicode default-ignorable format characters, so +/// find skips them in the page text and in the query alike: "Mitarbeiterinnen" matches a word +/// stored with soft hyphens. A visible word gap is its own space in the page text, so skipping a +/// zero width space next to one still keeps the two words apart. +fn is_invisible(c: char) -> bool { + matches!(c, '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}') +} + +/// A variation selector (VS1-VS16 and VS17-VS256): it picks a variant glyph of the character +/// before it and is no character of its own. +fn is_variation_selector(c: char) -> bool { + matches!(c as u32, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF) +} + +/// The last character before glyph `i` on the line that glyph `i - 1` ends, other than the +/// invisible characters find skips; a soft hyphen counts, since it marks a divided word. Looks +/// back no further than that line's start, so each line is read once. +fn last_kept(page: &PageText, i: usize) -> Option { + let end = i.checked_sub(1)?; + let line = page.line_of.get(end)?; + (0..=end) + .rev() + .take_while(|&k| page.line_of.get(k) == Some(line)) + .filter_map(|k| page.glyphs.get(k)) + .flat_map(|g| g.text.chars().rev()) + .find(|&c| c == '\u{AD}' || !is_invisible(c)) +} + +/// Whether `c` is set solid, with no spaces between words, in text that a line break may divide +/// anywhere: East Asian Width Wide, Fullwidth or Halfwidth (Unicode Standard Annex #11) Han, +/// kana, bopomofo, CJK symbols and punctuation and fullwidth forms other than Latin letters and +/// digits. Hangul is left out: Korean separates its words with spaces, so a line end there is a +/// word break. A line break between two such characters is the CSS segment-break rule's case for removing the break, not turning +/// it into a space (CSS Text Module, segment break transformation). +fn unspaced(c: char) -> bool { + matches!(c as u32, + 0x2E80..=0x2FFF // CJK radicals, Kangxi radicals, ideographic description characters + | 0x3001..=0x303E // CJK symbols and punctuation (U+3000 is a space; U+303F is narrow) + | 0x3041..=0x30FF // Hiragana, Katakana + | 0x3105..=0x312F // Bopomofo + | 0x3190..=0x31FF // Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions + | 0x3200..=0x4DBF // Enclosed CJK letters, CJK compatibility, Han extension A + | 0x4E00..=0x9FFF // Han + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical, compatibility and small forms + // Fullwidth forms, less the fullwidth digits and Latin letters (U+FF10-U+FF19, U+FF21-U+FF3A, + // U+FF41-U+FF5A): those are Latin text set wide, whose words still end at a line end. + | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF60 | 0xFFE0..=0xFFE6 + | 0xFF61..=0xFF9F // halfwidth CJK punctuation and Katakana (not halfwidth Hangul) + | 0x16FE0..=0x16FFF | 0x1AFF0..=0x1B2FF // ideographic symbols, kana supplements and extensions + | 0x20000..=0x3FFFD // Han extensions B and later, compatibility supplement + ) && !matches!(c as u32, 0x3200..=0x321E | 0x3260..=0x327E) // parenthesised and circled Hangul +} + +/// Whether glyph `i` starts the line that the text of glyph `i - 1` runs on to: both run the same +/// way, it sits past that glyph across the lines, by more than half the glyph's line height and +/// at most three, and starts no further along the line than half a line height past it, as a +/// wrapped line does (a line of one character starts level with the one before). Text set side by +/// side on one line (table cells, labels), a cell set lower and further along the row, a column +/// that starts back at the top, a block further down the page and a flow in another direction +/// keep their word break. +fn wraps(page: &PageText, i: usize) -> bool { + // Measured on the glyphs that own the visible characters either side of the break: an + // invisible glyph or a variation selector of its own (drawn with no height) says nothing about + // the lines. Each search stays within one line. + let Some(end) = i.checked_sub(1) else { return false }; + let visible = |k: &usize| page.glyphs.get(*k).is_some_and(|g| g.text.chars().any(|c| !is_invisible(c) && !is_variation_selector(c))); + let same_line = |k: &usize, of: usize| page.line_of.get(*k).is_some() && page.line_of.get(*k) == page.line_of.get(of); + let before = (0..=end).rev().take_while(|k| same_line(k, end)).find(visible).unwrap_or(end); + let after = (i..page.glyphs.len()).take_while(|k| same_line(k, i)).find(visible).unwrap_or(i); + let Some((before, after)) = page.glyphs.get(before).zip(page.glyphs.get(after)) else { return false }; + // Unit directions: the same flow has a dot product of 1. + let same_flow = before.direction[0] * after.direction[0] + before.direction[1] * after.direction[1] > 0.99; + let centre = |q: &[[f32; 2]; 4]| [q.iter().map(|p| p[0]).sum::() / 4.0, q.iter().map(|p| p[1]).sum::() / 4.0]; + let (a, b) = (centre(&before.quad), centre(&after.quad)); + // Towards the next line: the writing direction turned a quarter clockwise in y-down view space. + let [dx, dy] = before.direction; + let across = (b[0] - a[0]) * -dy + (b[1] - a[1]) * dx; + let along = (b[0] - a[0]) * dx + (b[1] - a[1]) * dy; + let [near, far] = [before.quad[0], before.quad[3]]; + let height = (near[0] - far[0]).hypot(near[1] - far[1]); + // NaN compares false: no join. + same_flow && across > height * 0.5 && across <= height * 3.0 && along < height * 0.5 +} + +/// The first visible character on the line that glyph `i` starts, skipping the invisible characters +/// that find drops, so a line that opens with a zero width character still joins on its first letter. +fn first_visible(page: &PageText, i: usize) -> Option { + let line = page.line_of.get(i)?; + page.glyphs.iter().zip(&page.line_of).skip(i).take_while(|(_, l)| *l == line).flat_map(|(g, _)| g.text.chars()).find(|&c| !is_invisible(c)) +} + /// Extend a prefix, falling back through strictly shorter proper prefixes on a mismatch. fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> usize { while matched > 0 && needle.get(matched) != Some(&c) { diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 44fbe49350..5b2a1e4c46 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -12,28 +12,53 @@ fn page(parts: &[&str]) -> PageText { // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; - let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + // Soft hyphens and zero width characters draw nothing and are skipped on both sides. + let visible = + |s: String| s.chars().filter(|c| !matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')).collect::(); + let needle: Vec = visible(fold(query)).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() || limit == 0 { return Vec::new(); } - let mut chars = Vec::new(); + // Each character, its glyph, and whether a skipped zero width space came right before it. + let mut chars: Vec<(char, usize, bool)> = Vec::new(); + let mut after_zwsp = false; for (i, glyph) in page.glyphs.iter().enumerate() { - if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') { - chars.push((' ', i)); + // A line that ends in a soft hyphen, past any empty or other invisible glyphs and + // characters after it on that line, runs on to the next. + let ends_in_soft_hyphen = |end: usize| { + (0..=end) + .rev() + .take_while(|&k| page.line_of[k] == page.line_of[end]) + .flat_map(|k| page.glyphs[k].text.chars().rev()) + .find(|&c| c == '\u{AD}' || !visible(c.to_string()).is_empty()) + == Some('\u{AD}') + }; + let line_break = i > 0 && page.line_of[i] != page.line_of[i - 1] && !ends_in_soft_hyphen(i - 1); + if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') { + chars.push((' ', i, false)); + after_zwsp = false; } for c in fold(&glyph.text).chars() { + after_zwsp |= c == '\u{200B}'; + if visible(c.to_string()).is_empty() { + continue; + } let c = if c.is_whitespace() { ' ' } else { c }; - if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { - chars.push((c, i)); + if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') { + chars.push((c, i, after_zwsp && c != ' ')); + after_zwsp = false; } } } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); + // A word ends at a skipped zero width space as it does at a space. + let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp); let mut hits = Vec::new(); let mut start = 0; while start + needle.len() <= chars.len() { let end = start + needle.len(); - if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { + let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end))); + if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) { hits.push(chars[start].1..chars[end - 1].1 + 1); if hits.len() == limit { break; @@ -69,7 +94,7 @@ fn whole_word_rejection_keeps_overlapping_candidates() { #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); @@ -110,3 +135,486 @@ fn long_repeated_prefix_search_preserves_the_last_match_and_limit() { assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]); assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty()); } + +/// pdf.js issue 13226 stores "Mitarbeiterinnen" with soft hyphens at its hyphenation points, and +/// issue 9186 puts zero width spaces next to the gaps between words. Find skips both characters +/// (and the other invisible joiners) in the page and in the query, and a match still starts and +/// ends on visible letters. +#[test] +fn find_skips_soft_hyphens_and_zero_width_characters() { + let word = page(&["M", "i", "t", "\u{AD}", "a", "r", "b", "e", "i", "\u{AD}", "t", "e", "r", "i", "n", "n", "e", "n"]); + assert_eq!(word.find("Mitarbeiterinnen"), vec![0..18]); + assert_eq!(word.find_opts("Mitarbeiterinnen", true, true), vec![0..18]); + assert_eq!(word.find("arbei"), vec![4..9]); + // Inside one word, a piece of it is not a whole word. + assert!(word.find_opts("terinnen", false, true).is_empty()); + // A query that carries the soft hyphens still matches. + assert_eq!(word.find("Mit\u{AD}arbei\u{AD}terinnen"), vec![0..18]); + + let mut gaps = page(&["is", "\u{200B}", "\u{200B}", "made", "\u{2060}", "as"]); + gaps.space_before = vec![false, false, true, false, false, true]; + assert_eq!(gaps.find("is made"), vec![0..4]); + assert_eq!(gaps.find_opts("made as", false, true), vec![3..6]); + for joiner in ['\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}'] { + assert_eq!(page(&["ab", &joiner.to_string(), "cd"]).find("abcd"), vec![0..3], "{joiner:?}"); + // The same character typed in the query is skipped too. + assert_eq!(page(&["ab", "cd"]).find_opts(&format!("ab{joiner}cd"), true, false), vec![0..2], "{joiner:?}"); + } + assert!(page(&["\u{200B}"]).find("\u{200B}").is_empty()); +} + +/// A soft hyphen at a line end is where the word was divided (ISO 32000-2 §14.8.2.3): the break +/// after it is no word break, so "Mitar\u{AD}" ending one line and "beiterinnen" starting the next +/// are found as one word, highlighted on both lines. A line break after any other glyph still +/// separates words. +#[test] +fn find_joins_a_word_divided_at_a_line_end_soft_hyphen() { + let parts: Vec = "Mitar\u{AD}beiterinnen".chars().map(String::from).collect(); + let mut text = page(&parts.iter().map(String::as_str).collect::>()); + for (i, glyph) in text.glyphs.iter_mut().enumerate() { + let x = i as f32 * 10.0; + glyph.rect = [x, 0.0, x + 10.0, 10.0]; + } + // "Mitar" and the soft hyphen on the first line, "beiterinnen" on the second. + for line in text.line_of.iter_mut().skip(6) { + *line = 1; + } + let hits = text.find("Mitarbeiterinnen"); + assert_eq!(hits, vec![0..17]); + assert_eq!(text.line_rects(hits[0].clone()).len(), 2); + assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![0..17]); + assert!(text.find("Mitar beiterinnen").is_empty()); + + // The soft hyphen as the last character of a longer glyph works the same way. + let mut text = page(&["Mitar\u{AD}", "beiterinnen"]); + text.line_of = vec![0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..2]); + + // Without the soft hyphen the line break is a word break. + let mut text = page(&["Mitar", "beiterinnen"]); + text.line_of = vec![0, 1]; + assert!(text.find("Mitarbeiterinnen").is_empty()); + assert_eq!(text.find("Mitar beiterinnen"), vec![0..2]); +} + +/// Horizontal glyphs of `size` points, one per character, each line `lead` points below the +/// last and starting at `x`: (x, first line's top, text) per line. +fn laid_out(lines: &[(f32, f32, &str)], size: f32) -> PageText { + let mut glyphs = Vec::new(); + for &(x0, top, text) in lines { + for (k, c) in text.chars().enumerate() { + let x = x0 + size * k as f32; + let (y0, y1) = (top, top + size); + glyphs.push(TextGlyph { + text: c.into(), + rect: [x, y0, x + size, y1], + quad: [[x, y1], [x + size, y1], [x + size, y0], [x, y0]], + direction: [1.0, 0.0], + }); + } + } + pdfcraft_render::text::layout(glyphs) +} + +#[test] +fn a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines() { + // "検知機構" wraps after "検知" (pdf.js #15340); "ソレノイド" after "ソレ". + let text = laid_out(&[(10.0, 10.0, "装置の検知"), (10.0, 25.0, "機構です"), (10.0, 40.0, "ソレ"), (10.0, 55.0, "ノイド")], 10.0); + assert_eq!(text.line_of.iter().copied().max(), Some(3)); + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..7]); + // One highlight per line, the end of the first line and the start of the second. + assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 10.0, 60.0, 20.0], [10.0, 25.0, 30.0, 35.0]]); + assert_eq!(text.find("ソレノイド"), vec![9..14]); + // The query typed with a space still finds nothing across the joined break. + assert!(text.find("検知 機構").is_empty()); + // Copied text keeps the line break. + assert_eq!(text.text_of(3..7), "検知\n機構"); +} + +#[test] +fn a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text() { + // Latin and Korean separate words with spaces: the line end is one. + let latin = laid_out(&[(10.0, 10.0, "abc"), (10.0, 25.0, "def")], 10.0); + assert_eq!(latin.find("abc def"), vec![0..6]); + assert!(latin.find("abcdef").is_empty()); + let mixed = laid_out(&[(10.0, 10.0, "PDF"), (10.0, 25.0, "検知")], 10.0); + assert_eq!(mixed.find("PDF 検知"), vec![0..5]); + assert!(mixed.find("PDF検知").is_empty()); + let hangul = laid_out(&[(10.0, 10.0, "한국"), (10.0, 25.0, "어")], 10.0); + assert_eq!(hangul.find("한국 어"), vec![0..3]); + assert!(hangul.find("한국어").is_empty()); + // Two cells on one row read as two lines, but the second does not continue the first. + let row = laid_out(&[(10.0, 10.0, "東京"), (200.0, 10.0, "大阪")], 10.0); + assert_eq!(row.line_of, vec![0, 0, 1, 1]); + assert!(row.find("東京大阪").is_empty()); + assert_eq!(row.find("東京 大阪"), vec![0..4]); + // Nor does a cell set lower and further along the row: a wrapped line starts back behind the + // end of the line before it. + let staggered = laid_out(&[(10.0, 10.0, "東京"), (200.0, 17.0, "大阪")], 10.0); + assert_eq!(staggered.line_of, vec![0, 0, 1, 1]); + assert!(staggered.find("東京大阪").is_empty()); + assert_eq!(staggered.find("東京 大阪"), vec![0..4]); + // Fullwidth Latin letters and digits are Latin text set wide: their line end is a word break. + let wide = laid_out(&[(10.0, 10.0, "AB"), (10.0, 25.0, "CD"), (10.0, 40.0, "12"), (10.0, 55.0, "34")], 10.0); + assert!(wide.find_opts("ABCD", true, false).is_empty()); + assert_eq!(wide.find_opts("AB CD", true, false), vec![0..4]); + assert!(wide.find("1234").is_empty()); + assert_eq!(wide.find("12 34"), vec![4..8]); +} + +/// Vertical glyphs (WMode 1) of `size` points, one per character, written downwards: each +/// column starts at `top` with its left edge at `x`: (x, top, text) per column, in reading order. +/// Each column is one line, as the text device groups a vertical flow. +fn columns(cols: &[(f32, f32, &str)], size: f32) -> PageText { + let mut text = PageText::default(); + for (line, &(x, top, chars)) in (0u32..).zip(cols) { + for (k, c) in chars.chars().enumerate() { + let y = top + size * k as f32; + // Written downwards: the em box's edge towards the next line is its left one. + text.glyphs.push(TextGlyph { + text: c.into(), + rect: [x, y, x + size, y + size], + quad: [[x, y], [x, y + size], [x + size, y + size], [x + size, y]], + direction: [0.0, 1.0], + }); + text.line_of.push(line); + text.space_before.push(false); + } + } + text +} + +#[test] +fn a_word_divided_between_vertical_columns_is_found_in_both() { + // Japanese set vertically: columns run top to bottom and follow each other to the left. + // "検知機構" breaks after "検知" at the foot of the right column. + let text = columns(&[(40.0, 10.0, "装置の検知"), (25.0, 10.0, "機構です")], 10.0); + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..7]); + // One highlight per column: the foot of the right one and the head of the left one. + assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 40.0, 50.0, 60.0], [25.0, 10.0, 35.0, 30.0]]); + assert_eq!( + text.text_of(3..7), + "検知 +機構" + ); + + // A column that stands to the right of the one before does not continue it. + let back = columns(&[(40.0, 10.0, "装置の検知"), (55.0, 10.0, "機構です")], 10.0); + assert!(back.find("検知機構").is_empty()); + assert_eq!(back.find("検知 機構"), vec![3..7]); +} + +/// A zero width space draws nothing and is skipped, but UAX #29 breaks words on both sides of it: +/// Thai and Khmer write it between words with no visible gap, so each word is still a whole word. +/// The joiners and the soft hyphen join, so they are no boundary. +#[test] +fn a_zero_width_space_is_a_word_boundary_for_whole_words() { + let thai = page(&["สวัสดี", "\u{200B}", "ครับ"]); + assert_eq!(thai.find_opts("ครับ", false, true), vec![2..3]); + assert_eq!(thai.find_opts("สวัสดี", false, true), vec![0..1]); + // The words read together, as typed with no space, still match. + assert_eq!(thai.find_opts("สวัสดีครับ", false, true), vec![0..3]); + // Inside one glyph's text too. + assert_eq!(page(&["ab\u{200B}cd"]).find_opts("cd", false, true), vec![0..1]); + for joiner in ["\u{AD}", "\u{200C}", "\u{200D}", "\u{2060}", "\u{FEFF}"] { + let text = page(&["ab", joiner, "cd"]); + assert!(text.find_opts("cd", false, true).is_empty(), "{joiner:?}"); + assert!(text.find_opts("ab", false, true).is_empty(), "{joiner:?}"); + assert_eq!(text.find_opts("abcd", false, true), vec![0..3], "{joiner:?}"); + } +} + +/// Every range `unspaced` lists joins a line it ends to the next one, on either side of the break; +/// Hangul, parenthesised and circled Hangul, fullwidth Latin and digits, Yi and ASCII keep a space. +#[test] +fn each_unspaced_range_joins_and_spaced_scripts_do_not() { + let joined = [ + '\u{2E80}', + '\u{2F00}', // CJK radicals, Kangxi radicals + '\u{3001}', + '\u{3006}', // CJK symbols and punctuation + '\u{3042}', + '\u{30A2}', // Hiragana, Katakana + '\u{3105}', // Bopomofo + '\u{3190}', + '\u{31A0}', + '\u{31C0}', + '\u{31F0}', // Kanbun, Bopomofo extended, strokes, Katakana extensions + '\u{3220}', + '\u{3300}', + '\u{3400}', // enclosed ideograph, CJK compatibility, Han extension A + '\u{4E2D}', // Han + '\u{F900}', // CJK compatibility ideographs + '\u{FE10}', + '\u{FE30}', + '\u{FE50}', // vertical, compatibility and small forms + '\u{FF01}', + '\u{FF1A}', + '\u{FF3B}', + '\u{FF5B}', + '\u{FFE0}', // fullwidth punctuation and signs + '\u{FF61}', + '\u{FF71}', // halfwidth CJK punctuation, halfwidth Katakana + '\u{16FE0}', + '\u{1AFF0}', + '\u{1B000}', // ideographic symbols, kana extended and supplement + '\u{20000}', + '\u{2F800}', + '\u{30000}', // Han extension B, compatibility supplement, extension G + ]; + for c in joined { + let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0); + assert_eq!(text.find_opts(&format!("中{c}{c}中"), true, false), vec![0..4], "U+{:04X}", c as u32); + } + let spaced = ['\u{AC00}', '\u{3200}', '\u{3260}', '\u{FF10}', '\u{FF21}', '\u{FF41}', '\u{FFA1}', '\u{A000}', 'a']; + for c in spaced { + let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0); + assert!(text.find_opts(&format!("中{c}{c}中"), true, false).is_empty(), "U+{:04X}", c as u32); + assert_eq!(text.find_opts(&format!("中{c} {c}中"), true, false), vec![0..4], "U+{:04X}", c as u32); + } +} + +/// The two line-end rules meet: a soft hyphen after a Chinese or Japanese character, a line of +/// Chinese that runs on, a German word divided by a soft hyphen, and a line that opens with a zero +/// width space, all on one page. Each break reads as nothing, and plain Latin line ends stay spaces. +#[test] +fn a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine() { + let text = laid_out( + &[ + (10.0, 10.0, "装置の検\u{AD}"), + (10.0, 25.0, "知機構の"), + (10.0, 40.0, "ソレ"), + (10.0, 55.0, "\u{200B}ノイド"), + (10.0, 70.0, "Mitar\u{AD}"), + (10.0, 85.0, "beiterinnen"), + (10.0, 100.0, "abc"), + (10.0, 115.0, "def"), + ], + 10.0, + ); + // Both rules fire at the first break: one hit, highlighted on both lines. + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..8]); + assert_eq!(text.line_rects(hits[0].clone()).len(), 2); + assert!(text.find("検 知機構").is_empty()); + // A line of Chinese or Japanese with no soft hyphen, and one that opens with a zero width space. + assert_eq!(text.find("機構のソレノイド"), vec![6..15]); + // A soft hyphen between Latin letters. + assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![15..32]); + // Latin with neither: still a word break. + assert_eq!(text.find("abc def"), vec![32..38]); + assert!(text.find("abcdef").is_empty()); +} + +/// Glyphs of `size` points as [`laid_out`] sets them, each line `lead` below the last, with the +/// writing direction turned to `direction` about the first glyph's corner (a unit vector in +/// y-down view space; `[1, 0]` is [`laid_out`]). +fn turned(lines: &[&str], size: f32, lead: f32, direction: [f32; 2]) -> PageText { + let [dx, dy] = direction; + // Towards the next line: the writing direction turned a quarter clockwise in view space. + let [nx, ny] = [-dy, dx]; + let at = |along: f32, across: f32| [100.0 + along * dx + across * nx, 100.0 + along * dy + across * ny]; + let mut glyphs = Vec::new(); + for (l, line) in lines.iter().enumerate() { + for (k, c) in line.chars().enumerate() { + let (a, b) = (size * k as f32, lead * l as f32); + // Baseline edge first, in the writing direction; then the far edge back. + let quad = [at(a, b + size), at(a + size, b + size), at(a + size, b), at(a, b)]; + let xs = quad.map(|p| p[0]); + let ys = quad.map(|p| p[1]); + let rect = [ + xs.iter().copied().fold(f32::MAX, f32::min), + ys.iter().copied().fold(f32::MAX, f32::min), + xs.iter().copied().fold(f32::MIN, f32::max), + ys.iter().copied().fold(f32::MIN, f32::max), + ]; + glyphs.push(TextGlyph { text: c.into(), rect, quad, direction }); + } + } + let mut text = PageText::default(); + for (l, line) in (0u32..).zip(lines) { + for _ in line.chars() { + text.line_of.push(l); + text.space_before.push(false); + } + } + text.glyphs = glyphs; + text +} + +/// Text turned a quarter either way, or upside down, runs on between lines as horizontal text +/// does: the line after is the next one across the turned lines, starting back at the turned +/// line's head. +#[test] +fn a_chinese_or_japanese_word_divided_in_turned_text_is_found_on_both_lines() { + for direction in [[0.0, -1.0], [0.0, 1.0], [-1.0, 0.0]] { + let text = turned(&["装置の検知", "機構です"], 10.0, 15.0, direction); + assert_eq!(text.find("検知機構"), vec![3..7], "{direction:?}"); + assert!(text.find("検知 機構").is_empty(), "{direction:?}"); + } +} + +/// The line end after a soft hyphen is found past other invisible characters that follow it, in +/// the same glyph or in glyphs of their own. +#[test] +fn a_soft_hyphen_line_end_joins_past_invisible_characters() { + let hyphen = laid_out(&[(10.0, 10.0, "Mitar\u{AD}\u{200B}"), (10.0, 25.0, "beiterinnen")], 10.0); + assert_eq!(hyphen.find("Mitarbeiterinnen"), vec![0..18]); + let mut text = page(&["Mitar\u{AD}", "\u{2060}", "beiterinnen"]); + text.line_of = vec![0, 0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..3]); +} + +/// A variation selector after the last Chinese or Japanese character of a line only picks its +/// glyph: the line still runs on to the next. +#[test] +fn a_variation_selector_at_a_line_end_keeps_the_line_running_on() { + let selector = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0); + assert_eq!(selector.find("葛\u{E0100}飾区"), vec![0..4]); + assert!(selector.find("葛\u{E0100} 飾区").is_empty()); +} + +/// A wrapped line is near the one before: a block far down the page keeps its word break. +#[test] +fn a_line_end_does_not_join_a_block_far_below() { + let far = laid_out(&[(10.0, 10.0, "検知"), (10.0, 200.0, "機構")], 10.0); + assert!(far.find("検知機構").is_empty()); + assert_eq!(far.find("検知 機構"), vec![0..4]); +} + +/// A wrapped line runs the same way as the one before: a line in another writing direction keeps +/// its word break, wherever it sits. +#[test] +fn a_line_end_does_not_join_a_line_in_another_direction() { + let mut across = laid_out(&[(10.0, 10.0, "検知"), (10.0, 25.0, "機構")], 10.0); + for g in across.glyphs.iter_mut().skip(2) { + g.direction = [0.0, 1.0]; + } + assert!(across.find("検知機構").is_empty()); + assert_eq!(across.find("検知 機構"), vec![0..4]); +} + +/// A line of one character starts level with the one before, not behind it, and still runs on. +#[test] +fn lines_of_one_character_run_on() { + let single = laid_out(&[(10.0, 10.0, "検"), (10.0, 25.0, "知"), (10.0, 40.0, "機")], 10.0); + assert_eq!(single.find("検知機"), vec![0..3]); +} + +/// A minimal PDF of `objects` (numbered from 1), each with its xref entry. +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The same vertical Japanese as [`a_word_divided_between_vertical_columns_is_found_in_both`], +/// drawn by a page in an Identity-V font: extracted text runs on from the foot of the right +/// column to the head of the left one. +#[test] +fn a_word_divided_between_vertical_columns_of_a_page_is_found_in_both() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let chars: Vec = "装置の検知機構です".chars().collect(); + let pairs: String = (1u32..).zip(&chars).map(|(code, c)| format!("<{code:04X}> <{:04X}> ", *c as u32)).collect(); + let to_unicode = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange {} beginbfchar {pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + chars.len() + ); + let content = "BT /F1 10 Tf 1 0 0 1 300 700 Tm <0001000200030004 0005> Tj 1 0 0 1 285 700 Tm <0006000700080009> Tj ET"; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 800] /Resources << /Font << /F1 4 0 R >> >> /Contents 7 0 R >>".into(), + "<< /Type /Font /Subtype /Type0 /BaseFont /TestMincho /Encoding /Identity-V /DescendantFonts [5 0 R] /ToUnicode 6 0 R >>".into(), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /TestMincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 1000 /CIDToGIDMap /Identity >>".into(), + stream("", &to_unicode), + stream("", content), + ]); + let mut renderer = PageRenderer::new(std::sync::Arc::new(bytes), RenderConfig::default()); + let result = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(result.error.is_none(), "{:?}", result.error); + let text = result.text.expect("text"); + assert_eq!(text.find("検知機構"), vec![3..7], "{:?}", text.plain_text()); + assert_eq!(text.line_rects(3..7).len(), 2, "one highlight per column"); + assert!(text.find("検知 機構").is_empty()); +} + +/// A line of Chinese or Japanese that ends in a zero width space drawn with no height still runs +/// on: the break is measured on the glyphs that draw the characters either side of it. +#[test] +fn a_line_end_after_an_invisible_glyph_with_no_height_still_joins() { + let mut text = laid_out(&[(10.0, 10.0, "検知\u{200B}"), (10.0, 25.0, "機構")], 10.0); + if let Some(g) = text.glyphs.get_mut(2) { + g.rect = [30.0, 15.0, 30.0, 15.0]; + g.quad = [[30.0, 15.0]; 4]; + } + assert_eq!(text.find("検知機構"), vec![0..5]); +} + +/// A long run of variation selectors, each a glyph of its own, is searched in time linear in the +/// page: the line-end check doesn't rescan the selectors before every glyph. +#[test] +fn a_long_run_of_variation_selectors_is_searched_quickly() { + let selectors: String = std::iter::once('葛').chain(std::iter::repeat_n('\u{FE00}', 200_000)).collect(); + let text = laid_out(&[(10.0, 10.0, &selectors)], 0.001); + let started = std::time::Instant::now(); + assert!(text.find("飾").is_empty()); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); +} + +/// A variation selector drawn as a glyph of its own with no height at a line end stays in the +/// searchable text, and the line still runs on: the break is measured on the letter it follows. +#[test] +fn a_line_end_after_a_zero_height_variation_selector_glyph_still_joins() { + let mut text = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0); + if let Some(g) = text.glyphs.get_mut(1) { + g.rect = [20.0, 15.0, 20.0, 15.0]; + g.quad = [[20.0, 15.0]; 4]; + } + assert_eq!(text.find("葛\u{E0100}飾区"), vec![0..4]); +} + +/// The visible glyphs either side of a break are found however many invisible glyphs stand +/// between them and the break. +#[test] +fn a_line_end_after_many_invisible_glyphs_still_joins() { + let line: String = std::iter::once('検').chain(std::iter::once('知')).chain(std::iter::repeat_n('\u{200B}', 40)).collect(); + let mut text = laid_out(&[(10.0, 10.0, &line), (10.0, 25.0, "機構")], 10.0); + for g in text.glyphs.iter_mut().skip(2).take(40) { + let [x, y] = [g.rect[0], g.rect[1]]; + g.rect = [x, y, x, y]; + g.quad = [[x, y]; 4]; + } + assert_eq!(text.find("検知機構"), vec![0..44]); +} + +/// The reference search and `find` agree on a soft hyphen followed, before the line end, by an +/// empty glyph and an invisible one: both join the word. +#[test] +fn reference_and_find_join_past_empty_and_invisible_glyphs_after_a_soft_hyphen() { + let mut text = page(&["Mitar\u{AD}", "", "\u{200B}", "beiterinnen"]); + text.line_of = vec![0, 0, 0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..4]); + assert_eq!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX), vec![0..4]); + + // A visible glyph after the soft hyphen ends the hyphenated word there: the line end stays a space. + let mut text = page(&["Mitar\u{AD}", "x", "", "\u{200B}", "beiterinnen"]); + text.line_of = vec![0, 0, 0, 0, 1]; + assert!(text.find("Mitarbeiterinnen").is_empty()); + assert!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX).is_empty()); + assert_eq!(text.find("Mitarx beiterinnen"), vec![0..5]); + assert_eq!(reference(&text, "Mitarx beiterinnen", false, false, usize::MAX), vec![0..5]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7d3249ddb7..d45e64e6d5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1406,8 +1406,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] -notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable", "crates/render/tests/it/search.rs::find_skips_soft_hyphens_and_zero_width_characters", "crates/render/tests/it/search.rs::find_joins_a_word_divided_at_a_line_end_soft_hyphen", "crates/render/tests/it/search.rs::a_zero_width_space_is_a_word_boundary_for_whole_words", "crates/render/tests/it/search.rs::a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines", "crates/render/tests/it/search.rs::a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text", "crates/render/tests/it/search.rs::a_word_divided_between_vertical_columns_is_found_in_both", "crates/render/tests/it/search.rs::each_unspaced_range_joins_and_spaced_scripts_do_not", "crates/render/tests/it/search.rs::a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine"] +notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic. Soft hyphens, zero width spaces and joiners, word joiners and U+FEFF inside a word are skipped, in the page and in the query; a skipped zero width space still ends a word for Whole words only. A line break reads as no word break when the line ends in a soft hyphen, or when it runs on between two Chinese or Japanese characters (East Asian Wide, Fullwidth or Halfwidth, not Hangul and not fullwidth Latin letters or digits), so a divided word is found, in horizontal lines and in vertical columns that continue to the left; the break stays a space beside Latin or Korean text and between text set side by side on one line or in staggered cells." [[feature]] id = "view.find-next-previous" From 286debd67ae120421070896c4d251978d049e0d8 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:58:56 -0500 Subject: [PATCH 27/29] pdfcraft-web: say how to wait for window.pdfcraft's functions (#6604) --- packaging/web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/web/README.md b/packaging/web/README.md index a066cab94a..f5a51b4259 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -160,7 +160,7 @@ The messages and both transports are the shared contract of [`craft-web-host`](h ### Driving it from the page (`window.pdfcraft`) -`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. +`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. The API is installed after `pdfcraft:ready` is posted, and `window.pdfcraft` is the canvas element before that, so wait for a function rather than for the name: `await new Promise(r => { const t = setInterval(() => typeof pdfcraft.request === "function" && (clearInterval(t), r()), 50); })`. ```js const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog From d65ae4268e20443005e5f728229269c3691c180a Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:59:19 +0800 Subject: [PATCH 28/29] M6: a calculate script that sets its own field through the field object keeps that value (form.custom-scripts) (#6616) --- crates/engine/src/js.rs | 2 ++ crates/engine/src/tests.rs | 60 +++++++++++++++++++++++++++++++++ crates/forms/src/scripting.rs | 4 ++- crates/forms/src/tests.rs | 62 +++++++++++++++++++++++++++++++++-- crates/forms/src/values.rs | 41 +++++++++++++++++++++-- crates/js/src/globals.rs | 26 ++++++++++++--- crates/js/src/model.rs | 2 ++ crates/js/src/run.rs | 3 +- crates/js/src/tests.rs | 15 +++++++++ parity/acrobat-features.toml | 4 +-- 10 files changed, 205 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index fead2dbebc..dce233279c 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -246,6 +246,7 @@ impl Scripts for JsRunner { self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { rc: true, value: value.to_string(), + value_set: false, changes: Vec::new(), message: None, }) @@ -282,6 +283,7 @@ impl JsRunner { Some(ScriptResult { rc: o.rc, value: o.value.clone(), + value_set: o.value_set, changes: changes(&states, &o.changed), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 553b2cb939..e75041fbbb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3159,6 +3159,66 @@ fn setting_the_calculation_order_recalculates_and_undoes() { assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); } +/// A Calculate script that sets its own field through the field object, not `event.value`, +/// gives the field that value, text included; when it sets `event.value` as well, that wins. +#[test] +fn a_calculate_script_setting_its_own_field_object_keeps_the_value() { + let (mut s, id) = session_with(1); + for (i, name) in ["origin", "copy", "both"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) }; + s.apply(id, script("copy", "var o = this.getField(\"origin\");\nthis.getField(\"copy\").value = o.value;")).unwrap(); + s.apply(id, script("both", "this.getField(\"both\").value = \"field\";\nevent.value = this.getField(\"origin\").value + \"!\";")).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("abc".into()) }).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()); + assert_eq!(value(&s, "copy").as_deref(), Some("abc")); + assert_eq!(value(&s, "both").as_deref(), Some("abc!"), "event.value decides when the script sets both"); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("12".into()) }).unwrap(); + assert_eq!(value(&s, "copy").as_deref(), Some("12")); +} + +/// A Calculate script that assigns `event.value` the value its field already holds keeps it, even +/// after writing another value through its field object; and a combo box a script moves to +/// another option selects that option in `/I` too. +#[test] +fn a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection() { + let (mut s, id) = session_with(1); + s.apply( + id, + Edit::AddField { page: 0, rect: [20.0, 250.0, 180.0, 272.0], kind: NewField::Text { multiline: false }, name: Some("origin".into()) }, + ) + .unwrap(); + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 210.0, 180.0, 232.0], kind: NewField::Text { multiline: false }, name: Some("kept".into()) }) + .unwrap(); + let kind = NewField::Combo { options: vec!["Small".into(), "Large".into()], editable: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 170.0, 180.0, 192.0], kind, name: Some("size".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "size".into(), value: FieldValue::Choice(vec!["Small".into()]) }).unwrap(); + let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) }; + s.apply( + id, + script( + "kept", + "var v = event.value; +this.getField(\"kept\").value = \"field\"; +event.value = v;", + ), + ) + .unwrap(); + s.apply(id, script("size", "event.value = this.getField(\"origin\").value > 5 ? \"Large\" : \"Small\";")).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("9".into()) }).unwrap(); + assert!(s.take_js_output(id).errors.is_empty(), "both scripts ran to the end"); + let doc = s.get(id).unwrap(); + let value = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()); + assert_eq!(value("kept").as_deref(), None, "event.value, set to the field's own value, decides"); + assert_eq!(value("size").as_deref(), Some("Large")); + let size = doc.form.iter().find(|f| f.name == "size").map(|f| f.obj).unwrap(); + let selected = doc.editor.as_ref().unwrap().cos.get(size).as_dict().and_then(|d| d.get(b"I")).cloned(); + assert_eq!(selected, Some(pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Int(1)]))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 0f7452d96e..93ed2d7c7a 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -53,6 +53,8 @@ pub struct ScriptResult { pub rc: bool, /// `event.value` afterwards. pub value: String, + /// Whether the script assigned `event.value`, even the value it already had. + pub value_set: bool, pub changes: Vec, /// Why it rejected the value (its alert), for the error shown. pub message: Option, @@ -84,7 +86,7 @@ pub struct NoScripts; impl Scripts for NoScripts { fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult { - ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None } } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f252927f05..09564c2dee 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1684,7 +1684,7 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { struct Ran; impl Scripts for Ran { fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { - ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None } } } let mut doc = one_page(); @@ -3151,6 +3151,7 @@ fn recalc_chain_where_script_changes_checkbox() { ScriptResult { rc: true, value: "calc1".into(), + value_set: true, changes: vec![FieldChange { name: "agree".into(), value: Some(vec!["Yes".into()]), @@ -3163,9 +3164,9 @@ fn recalc_chain_where_script_changes_checkbox() { } } else if script == "read_box" { let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default(); - ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: format!("box_was_{box_val}"), value_set: true, changes: Vec::new(), message: None } } else { - ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: String::new(), value_set: false, changes: Vec::new(), message: None } } } } @@ -3186,6 +3187,61 @@ fn recalc_chain_where_script_changes_checkbox() { assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value"); } +/// A calculation returning a combo option's display text stores the option's export value in /V +/// and selects it in /I, as choosing it does. +#[test] +fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() { + struct Pick; + impl Scripts for Pick { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false }; + add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &kind, Some("country")).unwrap(); + let obj = field(&fields(&doc), "country").obj; + let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]); + doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "Canada"), pair("fr", "France")]))).unwrap(); + set_field_script(&mut doc, "country", "calculate", Some("France")).unwrap(); + assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 1); + let dict = doc.get(obj).as_dict().cloned().unwrap(); + assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr")))); + assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)]))); + assert_eq!(field(&fields(&doc), "country").value, ["fr"]); + assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 0, "the display text names the value it already has"); +} + +/// An export value names its own option even when an earlier option shows that text, whether a +/// calculation or the user sets it. +#[test] +fn an_export_value_wins_over_an_earlier_options_display_text() { + struct Pick; + impl Scripts for Pick { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]); + let mut objs = Vec::new(); + for (name, y) in [("calculated", 10.0), ("chosen", 40.0)] { + let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false }; + add_field(&mut doc, 0, [10.0, y, 100.0, y + 20.0], &kind, Some(name)).unwrap(); + let obj = field(&fields(&doc), name).obj; + doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "fr"), pair("fr", "France")]))).unwrap(); + objs.push(obj); + } + set_field_script(&mut doc, "calculated", "calculate", Some("fr")).unwrap(); + recalculate_with(&mut doc, &mut Pick).unwrap(); + set_value(&mut doc, "chosen", &FieldValue::Choice(vec!["fr".into()])).unwrap(); + for obj in objs { + let dict = doc.get(obj).as_dict().cloned().unwrap(); + assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr")))); + assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)]))); + } +} + #[test] fn partial_appearance_keeps_custom_default_appearance_and_font_resources() { let mut doc = fixture(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index f2176e7fd5..3ac8efab91 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -163,6 +163,11 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result let current = f.value.first().cloned().unwrap_or_default(); let v = if let Some(js) = &f.actions.scripts.calculate { let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); + // A script may set its own field through the field object (`getField("Total").value + // = …`) rather than through `event.value`. `apply_changes` leaves the event's own + // field to the event, so that value is the result unless the script also assigned + // `event.value`, even the value it already had. + let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default()); scripting::apply_changes(doc, &r.changes, &f.name)?; if !r.changes.is_empty() { all = fields(doc); @@ -170,13 +175,37 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result if !r.rc { continue; } - r.value + match own { + Some(v) if !r.value_set => v, + _ => r.value, + } } else { let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; v }; + // A combo box result naming an option, by export value or display text, is stored as that + // option's export value (the first element of an /Opt pair, ISO 32000-2 Table 234), as + // `write_value` stores a choice, so /V, /I and exported form data agree. + let combo = f.kind == FieldKind::Combo; + let matched = if combo { option_named(&f, &v) } else { None }; + let v = match matched.and_then(|i| f.options.get(i)) { + Some((export, _)) => export.clone(), + None => v, + }; if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + // A combo box's /I (its option indices, ISO 32000-2 Table 234) follows the value, as + // `write_value` keeps it, so the selection kept never disagrees with the value. + let picked = matched.and_then(|i| i64::try_from(i).ok()); + doc.update_dict(f.obj, |d| { + d.set(b"V".to_vec(), PdfString::text(&v)); + match picked { + Some(i) if combo => d.set(b"I".to_vec(), Object::Array(vec![Object::Int(i)])), + _ if combo => { + d.remove(b"I"); + } + _ => {} + } + })?; redraw(doc, &f, std::slice::from_ref(&v), scripts)?; if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; @@ -187,6 +216,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result Ok(changed) } +/// The index of the choice option `v` names: the option whose export value it is, else the first +/// whose display text it is, so a display text never shadows a later option's export value. +fn option_named(f: &Field, v: &str) -> Option { + f.options.iter().position(|(e, _)| e == v).or_else(|| f.options.iter().position(|(_, d)| d == v)) +} + /// The fields whose calculation differs between two readings of a form, matched by field object /// and named as in `after`: the formulas an edit rewrote, such as a rename's. pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { @@ -280,7 +315,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr // Accept export values or display texts; free text only in editable combo boxes. let mut exports = Vec::new(); for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { + match option_named(f, v).and_then(|i| f.options.get(i)) { Some((e, _)) => exports.push(e.clone()), None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), None if f.options.is_empty() => exports.push(v.clone()), diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3b9ebe6028..4865ecf1c7 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -276,12 +276,29 @@ pub(crate) fn platform() -> &'static str { } } -pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { +/// The `event` object for `e`, and the store behind its `value`: `value` holds what the script +/// reads and writes, and `set` turns true once the script assigns `event.value`, even the value it +/// already had. The store is out of the script's reach. +pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, JsObject)> { + fn get(_: &JsValue, _: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult { + store.get(js_string!("value"), c) + } + fn set(_: &JsValue, args: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult { + store.set(js_string!("value"), arg(args, 0), false, c)?; + store.set(js_string!("set"), true, false, c)?; + Ok(JsValue::undefined()) + } + let store = ObjectInitializer::new(ctx) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("set"), false, Attribute::all()) + .build(); + let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone())); + let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone())); let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); - ObjectInitializer::new(ctx) + let event = ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) - .property(js_string!("value"), s(&e.value), Attribute::all()) + .accessor(js_string!("value"), Some(value_get), Some(value_set), Attribute::CONFIGURABLE | Attribute::ENUMERABLE) .property(js_string!("change"), s(&e.change), Attribute::all()) .property(js_string!("changeEx"), s(&e.change), Attribute::all()) .property(js_string!("willCommit"), e.will_commit, Attribute::all()) @@ -293,5 +310,6 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { .property(js_string!("commitKey"), 0, Attribute::all()) .property(js_string!("modifier"), false, Attribute::all()) .property(js_string!("shift"), false, Attribute::all()) - .build() + .build(); + Ok((event, store)) } diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 44d5ac2dee..591e2f472d 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -278,6 +278,8 @@ pub struct Outcome { pub rc: bool, /// `event.value` after the script. pub value: String, + /// Whether the script assigned `event.value`, even the value it already had. + pub value_set: bool, pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index ab7e32ed53..9435e7c8e4 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -217,7 +217,7 @@ fn run_here( let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; - let ev = event_object(&mut ctx, event); + let (ev, value_store) = event_object(&mut ctx, event)?; ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; // One document script that throws (calling a method PdfCraft lacks, say) must not keep // every field script in the form from running: what it defined before the error, and @@ -235,6 +235,7 @@ fn run_here( out.rc = rc.to_boolean(); let v = ev.get(js_string!("value"), &mut ctx)?; out.value = text(&v, &mut ctx)?; + out.value_set = value_store.get(js_string!("set"), &mut ctx)?.to_boolean(); let c = ev.get(js_string!("change"), &mut ctx)?; out.change = text(&c, &mut ctx)?; Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 92bd60c046..a12b78dcb6 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -31,6 +31,21 @@ fn calculate_scripts_read_fields_and_set_the_value() { assert!(o.rc); } +/// `event.value` reads and writes as a plain value, numbers included, and the outcome says whether +/// the script assigned it, even to the value it already had. +#[test] +fn the_outcome_says_whether_the_script_assigned_event_value() { + let ev = Event::field("Calculate", "total", "7"); + let o = go("event.value = event.value;", &ev); + assert_eq!((o.value.as_str(), o.value_set), ("7", true)); + let o = go("event.value = 2; event.value += 3; typeof event.value;", &ev); + assert_eq!((o.value.as_str(), o.value_set, o.result.as_deref()), ("5", true, Some("number"))); + let o = go("var v = event.value; this.getField('price').value = 3;", &ev); + assert_eq!((o.value.as_str(), o.value_set), ("7", false)); + let o = go("Object.keys(event).indexOf('value') >= 0;", &ev); + assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event"); +} + #[test] fn numbers_with_leading_zeros_stay_text() { let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", "")); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d45e64e6d5..7c0ca6696e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4794,8 +4794,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_script"] -evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"] -notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively." +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text"] +notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." [[feature]] id = "form.calculation-order" From bfed7bbabe963cdec035e303cc1b9826991363ba Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 9 Nov 2026 09:43:17 +0800 Subject: [PATCH 29/29] Fix main's build after the latest merges (#6660) --- crates/automation/src/bates.rs | 4 ++-- crates/engine/src/tests.rs | 12 ++++++++++-- crates/ui-egui/src/workflows/optimize_ui.rs | 2 +- crates/ui-egui/tests/it/status_layers.rs | 8 ++++++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index ffb2d42723..6493df9968 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -287,7 +287,7 @@ fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { }) .collect() } - return key(left) == key(right); + key(left) == key(right) } #[cfg(not(windows))] { @@ -307,7 +307,7 @@ fn output_probe_name(path: &Path) -> std::io::Result { wide.pop(); } } - return Ok(std::ffi::OsString::from_wide(&wide)); + Ok(std::ffi::OsString::from_wide(&wide)) } #[cfg(not(windows))] { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e75041fbbb..0c3a0caf1b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5310,6 +5310,7 @@ fn sign_prepared_with( v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); value(&mut v); + let certifies = v.get(b"Reference").is_some(); let sig = doc.add(Object::Dict(v)); // The field by name, never by number: a full save (encryption, repairs) is // free to renumber objects, and a hardcoded reference would stamp the value @@ -5319,6 +5320,12 @@ fn sign_prepared_with( let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields"); let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field"); doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + if certifies { + // A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2). + let mut perms = Dict::new(); + perms.set(b"DocMDP".to_vec(), Object::Ref(sig)); + doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap(); + } let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() }; let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap(); let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]); @@ -9181,8 +9188,9 @@ fn ocr_words_refresh_only_the_page_content_they_add() { let others = (words_on(&s, 0), words_on(&s, 2)); assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO"))); take_full_refreshes(); - let word = PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }; - assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None }]).unwrap(), 1); + let word = + PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0], confidence: None, source: "ocrs".into() }; + assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() }]).unwrap(), 1); assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document"); let after = s.get(id).unwrap(); assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view); diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 89f82b00a0..f54fc177f0 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -561,7 +561,7 @@ mod tests { assert!(app.optimize_run.is_none()); app.scan_clean_run = None; - app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, destination: None, progress: Arc::default() }); app.start_scan_clean(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); assert!(app.scan_clean_run.is_none()); diff --git a/crates/ui-egui/tests/it/status_layers.rs b/crates/ui-egui/tests/it/status_layers.rs index 812f6d44c8..cb0e178a2b 100644 --- a/crates/ui-egui/tests/it/status_layers.rs +++ b/crates/ui-egui/tests/it/status_layers.rs @@ -87,8 +87,16 @@ fn sign(id: &pdfcraft_engine::sign::pkcs12::DigitalId, file: Vec, more: impl v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; RESERVE], hex: true })); v.set(b"M".to_vec(), PdfString::literal(b"D:20261002120000+01'00'".to_vec())); more(&mut v); + let certifies = v.get(b"Reference").is_some(); let sig = doc.add(Object::Dict(v)); doc.update_dict(ObjRef::new(4, 0), |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + if certifies { + // A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2). + let mut perms = Dict::new(); + perms.set(b"DocMDP".to_vec(), Object::Ref(sig)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap(); + } let save = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; let mut out = pdfcraft_cos::write_incremental(&doc, &save).unwrap(); let br = out.windows(MARK.len()).position(|w| w == MARK.as_bytes()).unwrap();