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 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 17:19:21 +01:00
co-authored by Codex GPT-6.1
parent 2ab9c6992c
commit 393bd30701
3 changed files with 81 additions and 1 deletions
@@ -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 {
+1
View File
@@ -69,6 +69,7 @@ mod reading_colors;
mod recovery;
mod redact;
mod reload;
mod script_noop;
mod side_panels;
mod signing;
mod stamps;
+80
View File
@@ -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<u8> {
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<u8>>) -> Vec<u8> {
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");
}
}