fix(ui): selecting in an unchanged paragraph lands on the words the page shows (refs #5185) (#5390)

This commit is contained in:
Tiago Kiel authored and GitHub committed 2026-11-09 18:31:04 +08:00
1 parent 008b035326
commit 61bc0871a5
3 files changed
+239 -2

No files matched your search

+9
View File
@@ -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| {
+130 -2
View File
@@ -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<usize> = text.char_indices().map(|(at, _)| at).collect();
let chars: Vec<char> = 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<Option<(f32, f32)>> = vec![None; chars.len()];
let place = |range: std::ops::Range<usize>, 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<Vec<(f32, f32)>> = 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());
+100
View File
@@ -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<u8>, 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<String> = 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::<Vec<_>>());
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]