mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
This commit is contained in:
1 parent
008b035326
commit
61bc0871a5
3 files changed
+239
-2
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user