From 393bd3070154bc0cc0733b201b6109407d25a320 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:19:21 +0100 Subject: [PATCH] fix(ui): leave source text untouched when script conversion is a no-op Only request a script conversion from the panel; the caller decides whether conversion changed the text. Do not create a paragraph rewrite merely because the button was clicked. A hidden QQQ fixture checks both script buttons preserve document bytes, undo history and saved rendering, while supported conversions remain covered. Actual reverted-fix raster failure and scoped Linux native, wasm, clippy and repository checks passed. Co-Authored-By: Codex GPT-6.1 --- crates/ui-egui/src/panels/format_text_ui.rs | 1 - crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/script_noop.rs | 80 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/it/script_noop.rs diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 963032fc4e..dbb50f73ec 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -308,7 +308,6 @@ pub(crate) fn panel( let on = pdfcraft_engine::text_script::is_in(p.text, p.selection.as_ref(), script); if icons::button(ui, icon, 30.0, on, tip).clicked() { out.script = Some(script); - out.changed = true; } } } else { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 10a2c7ec36..221f9b754f 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -69,6 +69,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod script_noop; mod side_panels; mod signing; mod stamps; diff --git a/crates/ui-egui/tests/it/script_noop.rs b/crates/ui-egui/tests/it/script_noop.rs new file mode 100644 index 0000000000..ba113e2a4c --- /dev/null +++ b/crates/ui-egui/tests/it/script_noop.rs @@ -0,0 +1,80 @@ +//! A script button with no convertible characters must not rewrite hidden source text. +use egui_kittest::{Harness, kittest::Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest}; +use pdfcraft_ui_egui::PdfCraftApp; +use std::sync::Arc; + +fn hidden_text() -> Vec { + let body = "BT /F1 24 Tf 3 Tr 40 150 Td (QQQ) Tj ET"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_owned(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_owned(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +fn pixels(bytes: Arc>) -> Vec { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let rendered = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + rendered.rgba.to_vec() +} + +#[test] +fn unsupported_script_buttons_leave_hidden_text_bytes_history_and_saved_pixels_unchanged() { + for button in ["Superscript", "Subscript"] { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("hidden.pdf", None, hidden_text()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0).remove(0); + assert_eq!(block.text, "QQQ"); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let x = (block.rect[0] + block.rect[2]) as f32 / 2.0; + let y = (block.rect[1] + block.rect[3]) as f32 / 2.0; + let at = egui::pos2(page.left() + x / 300.0 * page.width(), page.top() + (300.0 - y) / 300.0 * page.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + let editor = h.state_mut().views[0].line_editor.as_mut().expect("hidden text editor opens"); + editor.selection = Some(1..2); + editor.focus = true; + h.run_steps(1); + let before = h.state().session.get(id).unwrap().bytes.clone(); + assert!(h.state().session.get(id).unwrap().can_undo().is_none()); + h.get_by_label(button).click(); + h.run_steps(4); + let saved = h.state_mut().session.save_bytes(id).unwrap(); + assert_eq!(pixels(saved.clone()), pixels(before.clone()), "{button} must preserve hidden text after saving"); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.bytes.as_ref(), before.as_ref(), "no conversion must leave source bytes untouched"); + assert!(doc.can_undo().is_none(), "no conversion must not create an edit"); + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("reopened.pdf", None, saved.as_ref().clone()).unwrap(); + assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().text_blocks(0)[0].text, "QQQ"); + } +}