mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 06:31:41 +00:00
This commit is contained in:
Generated
+1
@@ -4767,6 +4767,7 @@ dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"flate2",
|
||||
"fontconfig-parser",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user