M2: a page whose content was cut at the renderer's limits says so (#623) (#2113)

This commit is contained in:
mtlcl410
2026-11-09 18:09:37 +08:00
committed by GitHub
parent cc1d76f3fd
commit bd5db5dc35
23 changed files with 269 additions and 16 deletions
Generated
+1
View File
@@ -4767,6 +4767,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"flate2",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
+1
View File
@@ -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.
+4
View File
@@ -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.
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn tools() -> Vec<ToolDef> {
.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(
+28
View File
@@ -11652,6 +11652,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
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
+2 -1
View File
@@ -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::<Vec<_>>(), [n, 1]);
+1 -1
View File
@@ -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:?}");
}
+1 -1
View File
@@ -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 {
+22
View File
@@ -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<u8>| {
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]
+5 -1
View File
@@ -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<u32>,
/// `true` when a word break precedes the glyph on the same line.
pub space_before: Vec<bool>,
/// 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 {
+19 -3
View File
@@ -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)
}
+1 -1
View File
@@ -189,7 +189,7 @@ pub fn layout(glyphs: Vec<TextGlyph>) -> PageText {
let mut slots: Vec<Option<TextGlyph>> = 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.
+5 -2
View File
@@ -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");
+1
View File
@@ -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,
}
}
+2
View File
@@ -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).
+7
View File
@@ -563,6 +563,10 @@ pub struct DocView {
pages: HashMap<usize, PageTex>,
/// Pages the renderer could not draw, with the reason (never re-requested).
errors: HashMap<usize, String>,
/// Pages whose content was cut at the renderer's limits (#623): drawn and searched in part.
cut_pages: std::collections::BTreeSet<usize>,
/// Cut pages whose notice the reader dismissed: a page cut later shows it again.
cut_notice_dismissed: std::collections::BTreeSet<usize>,
/// When each still-missing page was first shown, to flag unusually slow renders.
waiting_since: HashMap<usize, f64>,
thumbs: HashMap<usize, PageTex>,
@@ -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;
+36 -2
View File
@@ -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
+12
View File
@@ -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<usize> {
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) => {
+57
View File
@@ -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("<form1><page1><name>Ada</name></page1></form1>");
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;
+2
View File
@@ -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::<Vec<_>>(),
// Pages cut at the renderer's limits (#623), 1-based.
"cut_pages": v.cut_pages().iter().map(|p| p + 1).collect::<Vec<_>>(),
// 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::<Vec<_>>(),
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
+1 -1
View File
@@ -176,7 +176,7 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa
})
.collect::<Vec<_>>();
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 {
+58
View File
@@ -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<u8> {
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<std::sync::Mutex<Option<pdfcraft_ui_egui::control::ControlClient>>> = 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}");
}
+2 -2
View File
@@ -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