From 61bc0871a5f97c9de4fe76d94fcee20fbee25569 Mon Sep 17 00:00:00 2001 From: Tiago Kiel <186439951+TiagoCK@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:31:04 -0500 Subject: [PATCH] fix(ui): selecting in an unchanged paragraph lands on the words the page shows (refs #5185) (#5390) --- crates/ui-egui/src/tools/edit_text_ui.rs | 9 ++ .../ui-egui/src/tools/edit_text_ui/layout.rs | 132 +++++++++++++++++- crates/ui-egui/tests/it/editing.rs | 100 +++++++++++++ 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 942b39674c..fc2cf29471 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -986,6 +986,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } else { caret_color_on(fill) }; + // An unchanged paragraph's galley is unseen over the page's own text. Selecting + // part of it must not paint its stand-in glyphs over that text in the selection + // colour (a serif paragraph turned sans when highlighted), nor hide it under an + // opaque fill: the selection is a tint the page shows through. + if unchanged { + let selection = &mut ui.visuals_mut().selection; + selection.stroke.color = Color32::TRANSPARENT; + selection.bg_fill = selection.bg_fill.gamma_multiply(0.45); + } let rows = 1; let snapshot = ed.clone(); let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 9acca88022..50a514f90e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -231,8 +231,6 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) }; let g = Arc::make_mut(&mut galley); - let mut bounds = Rect::NOTHING; - let mut meshes = Rect::NOTHING; // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where // the rewrite will put them, in the paragraph's width. let mut line = 0usize; @@ -274,6 +272,14 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f } } row.size.y = (ascent + descent).max(1.0); + } + if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 && block.rotation.abs() < 0.5 { + place_on_source_words(g, block, lines, text, scale); + } + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); @@ -283,6 +289,128 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f galley } +/// While a paragraph is unchanged the page shows its own text, and the editor's galley is drawn +/// unseen: it only places the caret and the selection. Put each source word's glyphs across that +/// word's box on the page, so a click or a drag lands on the word under the pointer. The editor's +/// face is often a stand-in whose advances differ from the PDF's (a font program it can't load, or +/// an OCR layer whose every word has its own size), and stretching each run to its width still let +/// every word boundary drift, so a drag over one word selected another two words along. +/// +/// A word takes its `ink` box when the run's words and boxes pair up, else the run's box spans all +/// its words. Within a word the stand-in's proportions are kept; spaces fill the gaps between +/// words. A row with a character this can't place, or with boxes out of reading order, is left as +/// laid out. +fn place_on_source_words(g: &mut egui::Galley, block: &pdfcraft_engine::TextBlock, lines: &[pdfcraft_engine::TextLine], text: &str, scale: f32) { + let starts: Vec = text.char_indices().map(|(at, _)| at).collect(); + let chars: Vec = text.chars().collect(); + let x = |user: f64| ((user - block.rect[0]) * f64::from(scale)) as f32; + // Each character's glyph advance, in the galley's character order (a newline has no glyph). + let mut advance = vec![0.0f32; chars.len()]; + let mut at = 0usize; + for placed in &g.rows { + for (glyph, slot) in placed.row.glyphs.iter().zip(advance.iter_mut().skip(at)) { + *slot = glyph.advance_width.max(0.0); + } + at = at.saturating_add(placed.row.glyphs.len()).saturating_add(usize::from(placed.ends_with_newline)); + } + if at != chars.len() { + return; + } + // Where each character goes: its left edge and width, in galley coordinates. + let mut target: Vec> = vec![None; chars.len()]; + let place = |range: std::ops::Range, left: f32, right: f32, target: &mut [Option<(f32, f32)>]| { + let natural: f32 = advance.get(range.clone()).map_or(0.0, |a| a.iter().sum()); + let count = range.len().max(1) as f32; + let mut pos = left; + for i in range { + let share = if natural > 0.0 { advance.get(i).copied().unwrap_or(0.0) / natural } else { 1.0 / count }; + let width = ((right - left) * share).max(0.0); + if let Some(slot) = target.get_mut(i) { + *slot = Some((pos, width)); + } + pos += width; + } + }; + for (range, index) in &block.spans { + let Some(line) = lines.get(*index) else { continue }; + let (first, end) = (starts.partition_point(|b| *b < range.start), starts.partition_point(|b| *b < range.end)); + let mut words = Vec::new(); + let mut i = first; + while i < end { + if chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + continue; + } + let from = i; + while i < end && chars.get(i).is_some_and(|c| !c.is_whitespace()) { + i += 1; + } + words.push(from..i); + } + let (Some(head), Some(tail)) = (words.first().cloned(), words.last().cloned()) else { continue }; + if line.ink.len() == words.len() { + for (word, ink) in words.iter().zip(&line.ink) { + place(word.clone(), x(ink[0]), x(ink[2]), &mut target); + } + } else { + place(head.start..tail.end, x(line.rect[0]), x(line.rect[2]), &mut target); + } + } + let mut first = 0usize; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); + let range = first..first.saturating_add(row.glyphs.len()); + first = range.end.saturating_add(usize::from(placed.ends_with_newline)); + let Some(mut spots) = target.get(range.clone()).map(<[_]>::to_vec) else { continue }; + let row_chars = chars.get(range).unwrap_or_default(); + // Spaces between two placed characters share the gap between them; at the row's ends + // they sit against the nearest placed character. + let mut i = 0; + while i < spots.len() { + if spots.get(i).is_some_and(Option::is_some) { + i += 1; + continue; + } + let from = i; + while i < spots.len() && spots.get(i).is_some_and(Option::is_none) { + i += 1; + } + if row_chars.get(from..i).is_none_or(|c| !c.iter().all(|c| c.is_whitespace())) { + break; + } + let left = from.checked_sub(1).and_then(|p| spots.get(p).copied().flatten()).map(|(at, w)| at + w); + let right = spots.get(i).copied().flatten().map(|(at, _)| at); + let (left, right) = match (left, right) { + (Some(l), Some(r)) => (l, r.max(l)), + (Some(l), None) => (l, l), + (None, Some(r)) => (r, r), + (None, None) => break, + }; + let width = (right - left) / (i - from) as f32; + for (k, spot) in spots.iter_mut().enumerate().take(i).skip(from) { + *spot = Some((left + width * (k - from) as f32, width)); + } + } + let placed_all: Option> = spots.into_iter().collect(); + let Some(spots) = placed_all else { continue }; + if spots.iter().any(|(at, w)| !at.is_finite() || !w.is_finite()) || spots.windows(2).any(|p| p[1].0 + 0.01 < p[0].0) { + continue; + } + for (glyph, (at, width)) in row.glyphs.iter_mut().zip(spots) { + let new_x = at - placed.pos.x; + let dx = new_x - glyph.pos.x; + glyph.pos.x = new_x; + glyph.advance_width = width; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.x += dx; + } + } + } + row.size.x = row.glyphs.iter().map(|g| g.pos.x + g.advance_width).fold(row.size.x.min(0.0), f32::max); + } +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 8b0bf25973..40a2da9b44 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6473,6 +6473,106 @@ fn the_caret_on_a_small_row_is_as_tall_as_that_row() { assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); } +/// One paragraph whose every word is its own run at its own size, in a font the editor can't load +/// (not embedded, and not a standard font), with glyphs much wider than its stand-in face's: the +/// shape of an OCR layer, and of a Type 1 paragraph from LaTeX. Returns the PDF and its words. +fn stand_in_words_fixture() -> (Vec, Vec<&'static str>) { + let words: Vec<(&str, f64)> = vec![ + ("In", 10.0), + ("poker", 10.3), + ("a", 15.1), + ("full", 11.7), + ("house", 10.7), + ("is", 9.7), + ("a", 15.1), + ("set", 9.0), + ("of", 10.4), + ("cards", 10.0), + ("that", 9.4), + ("make", 9.0), + ("a", 12.4), + ("pair", 11.7), + ]; + let mut content = String::new(); + let mut x = 20.0; + for (word, size) in &words { + content.push_str(&format!("BT /F1 {size} Tf {x:.2} 200 Td ({word}) Tj ET\n")); + x += word.len() as f64 * 0.7 * size + 4.0; + } + let widths = vec!["700"; 95].join(" "); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 700 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Font /Subtype /Type1 /BaseFont /CMR10 /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (out, words.into_iter().map(|(w, _)| w).collect()) +} + +/// A drag over a word of an unchanged paragraph selects that word. The editor lays the paragraph +/// out unseen in a stand-in face, and stretching each run to its width let every word boundary +/// drift: over an OCR layer, dragging across "kind" selected "of a", two words before it. +#[test] +fn dragging_over_a_word_selects_that_word_when_the_editor_uses_a_stand_in_face() { + let (bytes, words) = stand_in_words_fixture(); + 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(); + app.open_bytes("ocr-like.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 700.0; + let screen = |x: f64, y: f64| egui::pos2(page.left() + x as f32 * k, page.top() + (300.0 - y) as f32 * k); + let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0); + assert_eq!(lines.len(), words.len(), "one run per word: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let first = lines[0].rect; + let at = screen((first[0] + first[2]) / 2.0, (first[1] + first[3]) / 2.0); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + let opened = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(opened.as_deref(), Some(words.join(" ").as_str()), "the words open as one paragraph"); + // Drag across each of the last words, just inside its box, as a reader aims at what the page shows. + for target in [words.len() - 1, words.len() - 4, 9] { + let r = lines[target].rect; + let middle = (r[1] + r[3]) / 2.0; + let (from, to) = (screen(r[0] + 0.5, middle), screen(r[2] - 0.5, middle)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for step in 1..=6 { + h.hover_at(from + (to - from) * (step as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let ed = h.state().views[0].line_editor.as_ref().expect("the paragraph stays open"); + let selected = ed.selection.clone().and_then(|s| ed.text.get(s)).unwrap_or_default(); + assert_eq!(selected, words[target], "dragging over word {target} ({:?})", words[target]); + } +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test]