From bd5db5dc35093e02ddcfb245864cadc8a7e327ab Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:09:37 +0800 Subject: [PATCH] M2: a page whose content was cut at the renderer's limits says so (#623) (#2113) --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/automation/src/pages.rs | 4 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 28 +++++++++++ crates/engine/src/js.rs | 3 +- crates/engine/src/rich_copy.rs | 2 +- crates/render/examples/search_bench.rs | 2 +- crates/render/src/raster/tests.rs | 22 ++++++++ crates/render/src/text.rs | 6 ++- crates/render/src/text/device.rs | 22 ++++++-- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/tests.rs | 7 ++- crates/render/tests/it/search.rs | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/canvas.rs | 7 +++ crates/ui-egui/src/canvas/bars.rs | 38 +++++++++++++- crates/ui-egui/src/canvas/cache.rs | 12 +++++ crates/ui-egui/src/canvas/tests.rs | 57 +++++++++++++++++++++ crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/tools/comments/tests.rs | 2 +- crates/ui-egui/tests/it/view.rs | 58 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 23 files changed, 269 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c1bcdec8d..f9e243ba0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4767,6 +4767,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "fontconfig-parser", "getrandom 0.4.3", "image", diff --git a/ROADMAP.md b/ROADMAP.md index c2c7c6ad62..50576e6145 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -523,6 +523,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 04ab8dace4..f9ddec40a3 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -235,6 +235,10 @@ impl Automation { let (mut html_left, mut spent, mut cut) = (html_budget, false, false); for (&p, t) in pages.iter().zip(texts) { let mut page = json!({ "page": p + 1, "text": text(&t) }); + // Content cut at the renderer's limits (#623): the text is incomplete, and said so. + if t.truncated { + page["truncated"] = json!(true); + } if html { // Copy with formatting: the same glyphs as an HTML fragment, within one budget for // the whole call; a page past it gets none and the call says so. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 05d5753748..59fd154c14 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -246,7 +246,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") .ro() .cmd("edit.column_select") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 40334fae65..8758a6b0b4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11652,6 +11652,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { write_full(&doc, &SaveOptions::default()).unwrap() } +/// text_extract says when a page's text is incomplete because its content was cut at the +/// renderer's limits (#623); a page read whole carries no such mark. +#[test] +fn text_extract_says_when_a_pages_content_was_cut() { + use std::io::Write as _; + let dir = workdir("cut-content"); + // 288 MiB of whitespace, past the renderer's 256 MiB per-stream limit, as one Flate stream. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + std::fs::write(dir.join("cut.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let cut = ok(&mut a, "doc_open", json!({ "path": "cut.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": cut })); + assert_eq!(out["pages"][0]["truncated"], json!(true), "{out}"); + let whole = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": whole, "pages": [1] })); + assert!(out["pages"][0].get("truncated").is_none(), "{out}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index dce233279c..3867d7c533 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -604,7 +604,8 @@ mod review_tests { }; let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); glyphs.push(glyph(n, "x")); - let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + // Built by hand, not read from a page the renderer cut short, so the text is complete. + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false }; UNDERSCORE_CHARS_READ.with(|c| c.set(0)); let words = words_with(&text, |x, y| [x, y]); assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index 2e579adb19..a551ad2e9c 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -382,7 +382,7 @@ mod tests { let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); let mut s = Session::new(); let id = s.open("locked.pdf", None, bytes, None).unwrap(); - let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() }; + let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new(), truncated: false }; let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err(); assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}"); } diff --git a/crates/render/examples/search_bench.rs b/crates/render/examples/search_bench.rs index f65d959c5e..4e7d77c1ae 100644 --- a/crates/render/examples/search_bench.rs +++ b/crates/render/examples/search_bench.rs @@ -13,7 +13,7 @@ fn main() { let glyphs: Vec<_> = text.chars().map(|c| TextGlyph { text: c.to_string(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(); let n = glyphs.len(); - let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }; + let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; black_box(page.find(&query)); let mut times = Vec::new(); for _ in 0..7 { diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index fbe6dcede8..fab4e1f1ba 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5778,6 +5778,28 @@ fn content_bombs_stop_at_the_budget_and_say_so() { assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); } +/// The text of a cut page says so too (#623): what was extracted is incomplete, and a page +/// read whole is not marked. +#[test] +fn cut_content_marks_the_pages_text_incomplete() { + let text_of = |pdf: Vec| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + rx.recv_timeout(std::time::Duration::from_secs(60)).expect("text extraction must not stall") + }; + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let page = text_of(one_page_pdf(&bomb, "/Filter /FlateDecode")); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.text.as_ref().is_some_and(|t| t.truncated), "the text says it is incomplete"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "and the page says so"); + let page = text_of(one_page_pdf(b"1 0 0 rg 0 0 4 4 re f", "")); + assert!(page.text.as_ref().is_some_and(|t| !t.truncated), "a page read whole is not marked"); + assert!(page.warnings.is_empty()); +} + /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in /// a `/Contents` array a stream that is not Flate-coded, between two that are. #[test] diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index bc132466b1..dcdb9f2820 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -11,7 +11,8 @@ use hayro::hayro_interpret::font::Glyph; use hayro::hayro_interpret::hayro_syntax::Pdf; use hayro::hayro_interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::{HashMap, HashSet}; @@ -100,6 +101,9 @@ pub struct PageText { pub line_of: Vec, /// `true` when a word break precedes the glyph on the same line. pub space_before: Vec, + /// The page's content was cut at the renderer's limits (#623), so this text is incomplete: + /// whatever the cut part drew is missing, and a search over it can miss. + pub truncated: bool, } impl PageText { diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index a04e380788..afbe5150d7 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,7 +1,7 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; use super::{ - Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, - Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, + InterpreterWarning, PageText, Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, }; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be @@ -573,7 +573,22 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, // as in Acrobat, and match what other extractors report. - let settings = settings.clone(); + // The interpreter says when the content was cut at its limits (#623): the text is then + // marked incomplete, and the caller's own sink still hears it. + let cut = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let settings = { + let flag = cut.clone(); + let inner = settings.warning_sink.clone(); + InterpreterSettings { + warning_sink: std::sync::Arc::new(move |warning| { + if matches!(warning, InterpreterWarning::ContentTruncated) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + inner(warning); + }), + ..settings.clone() + } + }; let initial = p.initial_transform(true).to_kurbo(); let c = p.intersected_crop_box(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings); @@ -700,5 +715,6 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + text.truncated = cut.load(std::sync::atomic::Ordering::Relaxed); Ok(text) } diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index e147cda84d..eb858d39c0 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -189,7 +189,7 @@ pub fn layout(glyphs: Vec) -> PageText { let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); // Each glyph is emitted exactly once. let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); - PageText { glyphs, line_of, space_before } + PageText { glyphs, line_of, space_before, truncated: false } } /// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index c7601998b8..f0653059cd 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1255,7 +1255,7 @@ fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { let mut space_before = vec![false; 10]; space_before[5] = !line_break; let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); - let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let page = PageText { glyphs: glyphs.clone(), line_of, space_before, truncated: false }; let plain = if line_break { "quick\nbrown" } else { "quick brown" }; assert_eq!(page.plain_text(), plain); assert_eq!(page.find("quick "), vec![0..5]); @@ -1280,7 +1280,7 @@ fn phrase_search_ignores_repeated_spaces() { v.push(g(" ", 52.0, 10.0, 58.0)); word(&mut v, "brown", 58.0, 10.0, 6.0); let n = v.len(); - let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] }; + let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; assert_eq!(t.plain_text(), "quick brown"); assert_eq!(t.find("quick brown"), vec![0..12]); assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too"); @@ -1983,6 +1983,7 @@ fn words_and_lines_around_a_glyph() { })), line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], space_before: vec![false, false, false, false, false, false, true, false], + truncated: false, }; assert_eq!(t.word_at(1), Some((0, 1))); assert_eq!(t.word_at(3), Some((3, 4))); @@ -2482,6 +2483,7 @@ fn vertical_lines_keep_reading_order() { glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)], line_of: vec![0, 0, 1, 1], space_before: vec![false; 4], + truncated: false, }; let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]); assert_eq!(t.column_text(&all), "日本\n語文"); @@ -3176,6 +3178,7 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], line_of: vec![0, 0], space_before: vec![false, false], + truncated: false, }; assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 0c20e2c7b5..d78b18087a 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -6,6 +6,7 @@ fn page(parts: &[&str]) -> PageText { glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), line_of: vec![0; parts.len()], space_before: vec![false; parts.len()], + truncated: false, } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 994dfc8a1b..7f9f39894e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -94,6 +94,8 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } +# A content stream that inflates past the renderer's limits, for the cut-content notice test. +flate2 = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } # Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must # be enabled on the test graph itself (a dependency's dev-features do not propagate). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 26d1bd4946..5676ed1cae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -563,6 +563,10 @@ pub struct DocView { pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, + /// Pages whose content was cut at the renderer's limits (#623): drawn and searched in part. + cut_pages: std::collections::BTreeSet, + /// Cut pages whose notice the reader dismissed: a page cut later shows it again. + cut_notice_dismissed: std::collections::BTreeSet, /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, @@ -1021,6 +1025,8 @@ impl DocView { presentation: None, pages: HashMap::new(), errors: HashMap::new(), + cut_pages: Default::default(), + cut_notice_dismissed: Default::default(), waiting_since: HashMap::new(), thumbs: HashMap::new(), thumb_demand: HashMap::new(), @@ -1183,6 +1189,7 @@ impl DocView { fresh.shown = self.shown; fresh.highlight_fields = self.highlight_fields; fresh.notice_dismissed = self.notice_dismissed; + fresh.cut_notice_dismissed = std::mem::take(&mut self.cut_notice_dismissed); fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 3e12bd6ce3..a8a538e86c 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -59,8 +59,8 @@ pub(super) enum Notice { } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then the signature status bar, a form's scripts having been turned -/// off, security, forms and warnings. +/// snapshot is, then pages cut at the renderer's limits, then the signature status bar, a +/// form's scripts having been turned off, security, forms and warnings. pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, @@ -119,6 +119,13 @@ pub(super) fn notices( return Some(Notice::Close); } } + // Content cut at the renderer's limits (#623): what is missing is not visible, so the page + // is named in a bar of its own, which the signature, form and security bars below can't + // replace and dismissing them doesn't hide. Dismissing it covers the pages cut so far: a + // page found cut later shows it again. + if view.cut_pages.iter().any(|p| !view.cut_notice_dismissed.contains(p)) { + cut_pages_bar(view, ui, t); + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { @@ -242,6 +249,33 @@ pub(super) fn notices( open_security.then_some(Notice::Security).or(toggled) } +/// The bar naming the pages whose content was cut at the renderer's limits (#623). +fn cut_pages_bar(view: &mut DocView, ui: &mut egui::Ui, t: &Tokens) { + let Some(&first) = view.cut_pages.iter().next() else { return }; + let page = first.saturating_add(1).to_string(); + let n = view.cut_pages.len(); + let text = if n == 1 { + crate::i18n::fmt( + tl!("Page {page} holds more content than PdfCraft can read: part of it isn't drawn, and only part of its text can be found or copied."), + &[("page", &page)], + ) + } else { + crate::i18n::fmt( + tl!( + "{n} pages hold more content than PdfCraft can read, page {page} first: parts of them aren't drawn, and only part of their text can be found or copied." + ), + &[("n", &n.to_string()), ("page", &page)], + ) + }; + let mut dismiss = false; + notice_bar(ui, t, "triangle-alert", t.accent_text, text, |ui| { + dismiss = icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked(); + }); + if dismiss { + view.cut_notice_dismissed.extend(view.cut_pages.iter().copied()); + } +} + /// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the /// right. The buttons are laid out first, so the message only gets the width they leave: it /// wraps there (or, when very little is left, is cut short with the full text on hover) instead diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index 9a0abad953..c8f1d60f09 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -140,6 +140,12 @@ impl DocView { } } + /// Pages whose content was cut at the renderer's limits, so part of each is not drawn and + /// its text is incomplete (#623; for automation; 0-based, in order). + pub fn cut_pages(&self) -> Vec { + self.cut_pages.iter().copied().collect() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -186,6 +192,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.cut_pages.clear(); self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); @@ -229,6 +236,7 @@ impl DocView { self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); + self.cut_pages.remove(&page); self.last_queue.clear(); if self.selection.is_some_and(|s| s.page == page) { self.selection = None; @@ -565,6 +573,10 @@ impl DocView { return 0; } ctx.request_repaint(); + // Whether drawn or read for its text, a page cut at the limits is named (#623). + if r.warnings.contains(&pdfcraft_render::RenderWarning::ContentTruncated) { + self.cut_pages.insert(r.request.page); + } if r.request.kind == RequestKind::Text { match r.text { Some(t) => { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d8a5259251..85c7d3fac3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -484,6 +484,7 @@ fn history_search_view() -> DocView { }], line_of: vec![0], space_before: vec![false], + truncated: false, } .into(), ); @@ -550,6 +551,7 @@ fn find_test_text(s: &str) -> PageText { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, } } @@ -1178,6 +1180,7 @@ fn select_all_of_the_document_only_while_scrolling_through_several_pages() { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, }; let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); for p in 0..3 { @@ -1208,6 +1211,7 @@ fn text_layers_keep_the_selection_page() { glyphs: vec![pdfcraft_render::TextGlyph { text: "x".into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }], line_of: vec![0], space_before: vec![false], + truncated: false, }; for p in 0..100 { v.texts.insert(p, Arc::new(text.clone())); @@ -2064,6 +2068,59 @@ fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() { assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } +/// A cut page (#623) is named in a bar of its own: a static XFA form's notice, which comes +/// first among the form notices, still shows beside it instead of replacing it (review on #2113). +#[test] +fn a_cut_page_is_named_beside_an_xfa_form_notice() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = pdfcraft_xfa::fixtures::static_shell("Ada"); + app.open_bytes("form.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("XFA data are kept in step"); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label_contains("XFA data are kept in step"); +} + +/// Dismissing another notice doesn't hide a page found cut later (#623), and dismissing the +/// cut-page notice lasts until another page is found cut (review on #2113). +#[test] +fn a_page_found_cut_after_a_dismissal_is_still_named() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(300.0, 400.0, 2).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + // The reader dismissed the document's notice before the render found the cut page. + h.state_mut().views[0].notice_dismissed = true; + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the cut-page notice can be dismissed"); + // Re-reading the same page (an undo, a layer toggle) doesn't bring it back. + h.state_mut().views[0].invalidate_content(); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the dismissed page stays dismissed"); + h.state_mut().views[0].cut_pages.insert(1); + h.run_steps(2); + h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 12c98007cc..acccf5ead3 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -100,6 +100,8 @@ impl Host for crate::PdfCraftApp { "viewport": [v.viewport_rect().min.x, v.viewport_rect().min.y, v.viewport_rect().max.x, v.viewport_rect().max.y], "find_open": v.find.is_some(), "page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::>(), + // Pages cut at the renderer's limits (#623), 1-based. + "cut_pages": v.cut_pages().iter().map(|p| p + 1).collect::>(), // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index acb4008d3b..6e4623f818 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -176,7 +176,7 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa }) .collect::>(); let n = glyphs.len(); - PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] } + PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false } } fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index d912fa18a4..de90573ea1 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2630,3 +2630,61 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { sidebar_key(&mut h, Key::A, Modifiers::COMMAND); assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } + +/// A one-page PDF whose content inflates past the renderer's per-stream limit (#623): a window +/// of whitespace the interpreter can never complete, so the page is cut and says so. +fn cut_content_pdf() -> Vec { + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// A page whose content was cut at the renderer's limits is named in a notice, and listed for +/// automation (`ui.state`, by page number), instead of looking complete (#623). +#[test] +fn a_page_whose_content_was_cut_is_named_in_a_notice() { + let _gpu = gpu(); + let pdf = cut_content_pdf(); + let slot: std::sync::Arc>> = Default::default(); + let s = slot.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app.open_bytes("cut.pdf", None, pdf).expect("opens"); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().views[0].cut_pages().is_empty() { + assert!(std::time::Instant::now() < deadline, "the cut page was never reported; errors={:?}", h.state().views[0].page_errors()); + h.run_steps(2); + } + h.run_steps(2); + assert_eq!(h.state().views[0].cut_pages(), [0]); + assert!(h.state().views[0].page_errors().is_empty(), "a cut page still draws"); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + // Automation counts pages from 1, as the notice does (review on #2113). + let client = slot.lock().unwrap().take().expect("the control channel"); + let rx = client.send("ui.state", serde_json::json!({})); + // Bounded by wall-clock time rather than frames, so a busy runner doesn't fail it, and a + // dropped channel fails at once instead of stalling the run. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let state = loop { + h.run_steps(1); + match rx.try_recv() { + Ok(reply) => break reply.expect("ui.state"), + Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("ui.state: the control channel closed without a reply"), + Err(std::sync::mpsc::TryRecvError::Empty) => assert!(std::time::Instant::now() < deadline, "ui.state: no reply within 30 s"), + } + }; + assert_eq!(state["active"]["cut_pages"], serde_json::json!([1]), "{state}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec064518b..b15cc78541 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1879,8 +1879,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget"] -notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled" +evidence = ["crates/ui-egui/tests/perf.rs::scrolling_a_500_page_document_stays_within_the_frame_budget", "crates/ui-egui/tests/perf.rs::panels_with_hundreds_of_items_stay_within_the_frame_budget", "crates/render/src/raster/tests.rs::cut_content_marks_the_pages_text_incomplete", "crates/ui-egui/tests/it/view.rs::a_page_whose_content_was_cut_is_named_in_a_notice", "crates/automation/tests/it/automation.rs::text_extract_says_when_a_pages_content_was_cut"] +notes = "UI work per frame ~6 ms scrolling 500 pages (comments panel with 500 cards ~6 ms, thumbnails ~2 ms); rendering is off-thread and tiled. A page cut at the renderer's limits is named in a notice, listed under active.cut_pages by ui.state, and its text_extract text carries truncated: true (#623)" # ================================================================================================== # C. Content editing