From 9194ec457f6f9c063ccad4df5f508b10642155a3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:00:45 +0800 Subject: [PATCH 1/2] M7: the paragraph editor draws on its page only (#3061) A paragraph running past its page's bottom edge, as HTML-to-PDF converters write when they rely on the page clip, opened in an editor that covered the next page with its box and text, and paragraph outlines crossed the gap between pages. The editor is clipped to its page within the document area (it also covered the tool bars when scrolled up), and outlines to their page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/ui-egui/src/edit_text_ui.rs | 8 +- crates/ui-egui/tests/editing.rs | 113 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe6..e19caf6865 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -989,7 +989,8 @@ pub(crate) fn page_input( return true; } let boxes: Vec = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect(); - let painter = ui.painter(); + // Outlines stay on their page, like its text (#3061). + let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(xf.rect.expand(1.0))); let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); for (i, b) in boxes.iter().enumerate() { if active != Some(i) { @@ -1313,6 +1314,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .as_ref() .is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible)); let draft_rect = view.text_background.draft_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); + // The editor draws on its page only, as the page itself does: a paragraph running past the + // page's edge (a converter relying on the page clip) must not cover the next page or the + // tool bars (#3061). + let page_clip = xf.rect.intersect(view.viewport_rect()); let ed = view.line_editor.as_mut()?; if hold { ed.focus = true; @@ -1340,6 +1345,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( ctx, |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(page_clip)); // The editor opens at the source size. Once text changes, show the real draft // extent: single rows grow toward the page edge, paragraphs rewrap at their // source width, and the visible frame grows/shrinks vertically with their rows. diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 680d49a777..0194a4241e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4806,3 +4806,116 @@ fn read_mode_shortcut_applies_the_open_paragraph() { assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode"); assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied"); } + +/// #3061: a paragraph running past its page's bottom edge (an HTML converter relying on the +/// page clip) opens in an editor that draws on its page only. It covered the next page. +#[test] +fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { + let text = |first: &str, count: usize, leading: usize| { + let mut s = String::from("BT /F1 14 Tf\n"); + for i in 0..count { + s.push_str(&format!("1 0 0 1 72 {} Tm ({first} line {}: the client may cancel this) Tj\n", 720 - (leading * i) as i64, i + 1)); + } + s.push_str("ET"); + s.into_bytes() + }; + // Page 1's single paragraph goes down to y = -270; page 2 is ordinary. + let (one, two) = (text("Page one", 56, 18), text("PAGE TWO", 3, 40)); + let stream = |d: &[u8]| [format!("<< /Length {} >>\nstream\n", d.len()).into_bytes(), d.to_vec(), b"\nendstream".to_vec()].concat(); + let page = |contents: usize| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {contents} 0 R /Resources << /Font << /F1 7 0 R >> >> >>") + .into_bytes() + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_vec(), + page(4), + stream(&one), + page(6), + stream(&two), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".to_vec(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("off-page.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + h.run_steps(4); + let id = h.state().views[0].id; + let info = h.state().session.get(id).unwrap().info.pages[0].clone(); + let (first, second) = (h.state().views[0].page_screen_rect(0).unwrap(), h.state().views[0].page_screen_rect(1).unwrap()); + shown_on_its_page_only(&mut h, first, second, "the paragraph's outline"); + let v = info.user_to_view(200.0, 378.0); + let p = first.min + egui::vec2(v[0], v[1]) * (first.width() / info.width); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")), + "the paragraph's off-page lines are in it" + ); + shown_on_its_page_only(&mut h, first, second, "the open editor"); + // Scrolled up past the tool bar, the editor stays inside the document area. + let viewport = h.state().views[0].viewport_rect(); + let above = viewport.top() - 3.0; + let before = h.render().unwrap(); + h.hover_at(egui::pos2(first.right() - 20.0, first.center().y)); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(4); + let scrolled = h.state().views[0].page_screen_rect(0).unwrap(); + assert!(scrolled.top() < viewport.top() - 100.0, "scrolled: {scrolled:?} in {viewport:?}"); + assert!(h.state().views[0].line_editor.is_some(), "still editing"); + let after = h.render().unwrap(); + for x in (first.left() as u32 + 70)..(first.left() as u32 + 420) { + assert_eq!(after.get_pixel(x, above as u32), before.get_pixel(x, above as u32), "above the document area at x = {x}"); + } +} + +/// Between pages `first` and `second`, and on `second` above its own text, across where page +/// 1's paragraph is: what is there without it (the gap's colour right of the paragraph, white +/// paper). +fn shown_on_its_page_only(h: &mut Harness<'static, PdfCraftApp>, first: egui::Rect, second: egui::Rect, what: &str) { + let shot = h.render().unwrap(); + let pixel = |x: f32, y: f32| shot.get_pixel(x as u32, y as u32).0; + let gap = pixel(first.right() - 40.0, (first.bottom() + second.top()) / 2.0); + for x in (first.left() as u32 + 70)..(first.left() as u32 + 420) { + let x = x as f32; + assert_eq!(pixel(x, (first.bottom() + second.top()) / 2.0), gap, "{what}: between the pages at x = {x}"); + for dy in [4.0, 20.0, 40.0] { + let [r, g, b, _] = pixel(x, second.top() + dy); + assert!(r > 240 && g > 240 && b > 240, "{what}: page 2 at {x}, +{dy}: {r},{g},{b}"); + } + } +} From de25be16e5982bdd3acbc7b618807a650ac9f37c Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:50:05 +0800 Subject: [PATCH 2/2] test: the off-page paragraph test compares the gap with the same pixels without Edit text (#3061) Review on #3070: the gap between pages isn't one colour on every platform; on ubuntu CI the pixel at x = 416 was one level off the fixed sample taken 40 px from the page's right edge. The test now takes a screenshot of the same pages before Edit text is on (no paragraph outline), checks that the pages stay where they were, and compares every gap pixel with the same pixel there. Removing either clip still fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R3niW7kVeT8mi7TpWc8eAJ --- crates/ui-egui/tests/editing.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 0194a4241e..b202151603 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4856,13 +4856,20 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { app }); h.run_steps(4); - assert!(h.state_mut().execute("edit.edit_text")); h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; h.run_steps(4); + let rects = + |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].page_screen_rect(0).unwrap(), h.state().views[0].page_screen_rect(1).unwrap()); + // The same pages without Edit text, so without the paragraph's outline: what the gap looks + // like there, pixel by pixel (review on #3070: it isn't one colour on every platform). + let (first, second) = rects(&h); + let plain = h.render().unwrap(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(rects(&h), (first, second), "Edit text keeps the pages where they were"); let id = h.state().views[0].id; let info = h.state().session.get(id).unwrap().info.pages[0].clone(); - let (first, second) = (h.state().views[0].page_screen_rect(0).unwrap(), h.state().views[0].page_screen_rect(1).unwrap()); - shown_on_its_page_only(&mut h, first, second, "the paragraph's outline"); + shown_on_its_page_only(&mut h, &plain, first, second, "the paragraph's outline"); let v = info.user_to_view(200.0, 378.0); let p = first.min + egui::vec2(v[0], v[1]) * (first.width() / info.width); h.hover_at(p); @@ -4881,7 +4888,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { h.state().views[0].line_editor.as_ref().is_some_and(|e| e.page == 0 && e.text.contains("line 50")), "the paragraph's off-page lines are in it" ); - shown_on_its_page_only(&mut h, first, second, "the open editor"); + shown_on_its_page_only(&mut h, &plain, first, second, "the open editor"); // Scrolled up past the tool bar, the editor stays inside the document area. let viewport = h.state().views[0].viewport_rect(); let above = viewport.top() - 3.0; @@ -4904,15 +4911,15 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { } /// Between pages `first` and `second`, and on `second` above its own text, across where page -/// 1's paragraph is: what is there without it (the gap's colour right of the paragraph, white -/// paper). -fn shown_on_its_page_only(h: &mut Harness<'static, PdfCraftApp>, first: egui::Rect, second: egui::Rect, what: &str) { +/// 1's paragraph is: what is there without it (the gap as `plain` shows it without Edit text, +/// white paper). +fn shown_on_its_page_only(h: &mut Harness<'static, PdfCraftApp>, plain: &image::RgbaImage, first: egui::Rect, second: egui::Rect, what: &str) { let shot = h.render().unwrap(); let pixel = |x: f32, y: f32| shot.get_pixel(x as u32, y as u32).0; - let gap = pixel(first.right() - 40.0, (first.bottom() + second.top()) / 2.0); + let gap_y = (first.bottom() + second.top()) / 2.0; for x in (first.left() as u32 + 70)..(first.left() as u32 + 420) { let x = x as f32; - assert_eq!(pixel(x, (first.bottom() + second.top()) / 2.0), gap, "{what}: between the pages at x = {x}"); + assert_eq!(pixel(x, gap_y), plain.get_pixel(x as u32, gap_y as u32).0, "{what}: between the pages at x = {x}"); for dy in [4.0, 20.0, 40.0] { let [r, g, b, _] = pixel(x, second.top() + dy); assert!(r > 240 && g > 240 && b > 240, "{what}: page 2 at {x}, +{dy}: {r},{g},{b}");