M3: copied text keeps one line per baseline, reading order for turned lines, letters for ligatures (#4467) (#4468)

This commit is contained in:
mtlcl410
2026-11-09 18:11:40 +08:00
committed by GitHub
parent 08c3d3a5ab
commit 7eea609c5d
9 changed files with 536 additions and 19 deletions
+2 -1
View File
@@ -9050,7 +9050,8 @@ fn cut_stack_printing_through_tools() {
);
assert_eq!(r["pages"], 5);
let printed = ok(&mut a, "doc_open", json!({"path": "range.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, printed), ["Page 10\nPage 4", "Page 8\nPage 2", "Page 6"]);
// Two pages side by side: their labels share a baseline, so a line.
assert_eq!(page_text(&mut a, printed), ["Page 10 Page 4", "Page 8 Page 2", "Page 6"]);
assert_eq!(page_text(&mut a, doc), before);
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false);
for duplex in ["long-edge", "short-edge"] {
+1
View File
@@ -17,4 +17,5 @@ mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
mod text_copy;
mod unreadable_objects;
+106
View File
@@ -0,0 +1,106 @@
//! `text_extract` and `text_find` on a page of the cases text copy has to get right: words drawn out
//! of order, runs of different sizes on one baseline, a ligature glyph named in the encoding and a
//! line turned upwards in a row of text.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// A fresh directory for one test.
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-text-copy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// One page of labelled rows (`/F1` Helvetica, `/F2` Helvetica with code 128 named `/fi`):
/// 01 `of<fi>ce`; 06 `exam-` over `ple`; 07 two columns of two lines; 08 "second" drawn before
/// "first", to its right; 11 a 14 pt invisible word and a 9 pt note on one baseline; 12 letter
/// spacing; 13 "up" set upwards; 14 two words placed by `Td`.
fn copy_pdf() -> Vec<u8> {
let rows = [
("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"),
("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"),
("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"),
("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"),
("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"),
("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"),
("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"),
("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"),
];
let mut body = String::new();
let mut y = 740;
for (tag, row) in rows {
body += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", row.replace("{Y}", &y.to_string()));
y -= match tag {
"06" | "13" => 48,
"07" => 44,
_ => 38,
};
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>"
.to_string(),
];
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
}
/// Copy keeps a baseline on one line in left-to-right order, columns column by column, the
/// ligature as its letters and the turned line in its row; Find matches what Copy reads, and its
/// rectangles cover the glyphs it names.
#[test]
fn text_extract_and_find_read_one_line_per_baseline() {
let dir = tmp("baseline");
std::fs::write(dir.join("copy.pdf"), copy_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "copy.pdf" }))["doc"].as_u64().unwrap();
let text = ok(&mut a, "text_extract", json!({ "doc": doc }));
assert_eq!(
text["pages"][0]["text"],
"01 office\n06 exam-\nple\n07 left one\nleft two\nright one\nright two\n08 first second\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words"
);
let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }));
let first_second = find(&mut a, "first second");
assert_eq!(first_second["count"], 1);
assert_eq!(first_second["matches"][0]["text"], "first second");
// One rectangle across both words, from the left edge of "first" (x = 90) past the right edge of "second".
let rects = first_second["matches"][0]["rects"].as_array().unwrap();
assert_eq!(rects.len(), 1, "{rects:?}");
let x = |k: usize| rects[0][k].as_f64().unwrap();
assert!((x(0) - 90.0).abs() < 0.5 && x(2) > 200.0, "{rects:?}");
let office = find(&mut a, "office");
assert_eq!(office["count"], 1);
assert_eq!(office["matches"][0]["text"], "office");
assert_eq!(find(&mut a, "hidden <- invisible")["count"], 1);
assert_eq!(find(&mut a, "left one right one")["count"], 0, "the columns stay apart");
let _ = std::fs::remove_dir_all(&dir);
}
+12 -4
View File
@@ -319,13 +319,21 @@ impl PageText {
/// turned by `/Rotate`) rows mean nothing, and the lines come out one per row in reading order
/// instead.
pub fn column_text(&self, glyphs: &[usize]) -> String {
// The selected glyphs of each line, with their box: (glyphs, bbox).
// The selected glyphs of each line, with their box: (glyphs, bbox). A line is cut where a gap wider than one run
// of text can hold separates two glyphs (`arrange` joins runs on one baseline into a line, as table cells are), so
// cells keep their tab.
let mut lines: Vec<(Vec<usize>, [f32; 4])> = Vec::new();
let mut line_ids: Vec<u32> = Vec::new();
let mut previous: Option<&TextGlyph> = None;
for &i in glyphs {
let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue };
let apart = previous.is_some_and(|p| {
let h = (p.rect[3] - p.rect[1]).max(g.rect[3] - g.rect[1]);
g.rect[0] - p.rect[2] >= CELL_GAP * h
});
previous = Some(g);
match (lines.last_mut(), line_ids.last()) {
(Some((members, bb)), Some(&l)) if l == line => {
(Some((members, bb)), Some(&l)) if l == line && !apart => {
members.push(i);
union(bb, &g.rect);
}
@@ -390,9 +398,9 @@ mod device;
mod layout;
mod order;
use device::WORD_GAP;
use device::{CELL_GAP, WORD_GAP};
#[cfg(test)]
use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice};
use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice, ligature_letters};
pub(crate) use device::{extract_page, extract_page_filtered};
pub use layout::layout;
#[cfg(test)]
+125 -3
View File
@@ -1,4 +1,4 @@
use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled};
use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, union, unrolled};
use super::{
Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings,
InterpreterWarning, PageText, Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page,
@@ -13,6 +13,10 @@ pub(super) const DIRECTION_TOLERANCE: f64 = WORD_GAP as f64;
/// A word gap is wider than the line's typical letter gap by this fraction of the line height.
pub(super) const WORD_GAP: f32 = 0.15;
/// A gap of this many heights between two glyphs of a line is wider than one run of text holds (`arrange` continues
/// a run across less than three heights), so it separates cells that share a baseline.
pub(super) const CELL_GAP: f32 = 3.0;
/// A glyph with its writing direction to the nearest quarter turn (right, down, left, up) and as
/// a unit vector.
pub(super) type Drawn = (usize, [f64; 2], TextGlyph);
@@ -397,6 +401,35 @@ impl TextDevice {
}
}
/// `text` with each Latin compatibility ligature (U+FB00–U+FB06: ff, fi, fl, ffi, ffl, long s t,
/// st) written as the letters it stands for, their compatibility decomposition. A font whose
/// encoding names a ligature glyph (`/fi`) reads as the ligature character, and copied or searched
/// text wants the letters, as a ToUnicode map gives them (ISO 32000-2 §9.10.3 maps the ff, fi and
/// ffl glyphs to "LATIN SMALL LETTER F … I"). Nothing else is normalised. The glyph keeps its one
/// box: the PDF gives no position for the letters inside it.
pub(super) fn ligature_letters(text: String) -> String {
let letters = |c: char| match c {
'\u{FB00}' => Some("ff"),
'\u{FB01}' => Some("fi"),
'\u{FB02}' => Some("fl"),
'\u{FB03}' => Some("ffi"),
'\u{FB04}' => Some("ffl"),
'\u{FB05}' | '\u{FB06}' => Some("st"),
_ => None,
};
if !text.chars().any(|c| letters(c).is_some()) {
return text;
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match letters(c) {
Some(s) => out.push_str(s),
None => out.push(c),
}
}
out
}
/// Whether `PDFCRAFT_TEXT_DEBUG` is set. `draw_glyph` runs once per glyph and `getenv` scans the
/// whole environment under a lock, so read it once.
fn text_debug() -> bool {
@@ -450,6 +483,7 @@ impl<'a> Device<'a> for TextDevice {
if !replaced && text.chars().all(char::is_control) {
return;
}
let text = ligature_letters(text);
// Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em
// box spans descender (−200) to ascender (800), the advance gives the width.
let t = transform * glyph_transform;
@@ -653,7 +687,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
flows.sort_by(|a, b| a.top.total_cmp(&b.top));
flows
};
let flows = upright(quarters);
let mut flows = upright(quarters);
let (main, mut inline, place) = turned_lines_in_place(&glyphs, &mut flows);
let mut extra: Vec<Flow> = curves
.into_iter()
.map(|ids| (None, ids))
@@ -699,7 +734,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
text.space_before.push(space);
}
};
for flow in flows {
let placed = |ids: &[usize]| ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX);
for (fi, flow) in flows.into_iter().enumerate() {
let mut k = 0;
while k < flow.ids.len() {
let line = flow.line_of[k];
@@ -708,9 +744,20 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
while let Some(f) = extra.next_if(|f| first(&f.ids) < before) {
emit(&mut text, &f.ids, &f.line_of, &f.space_before, None);
}
if Some(fi) == main {
let at = placed(&flow.ids[k..end]);
while let Some(l) = inline.next_if(|l| l.at < at) {
emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter);
}
}
emit(&mut text, &flow.ids[k..end], &vec![0; end - k], &flow.space_before[k..end], flow.quarter);
k = end;
}
if Some(fi) == main {
for l in inline.by_ref() {
emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter);
}
}
}
for f in extra {
emit(&mut text, &f.ids, &f.line_of, &f.space_before, None);
@@ -718,3 +765,78 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
text.truncated = cut.load(std::sync::atomic::Ordering::Relaxed);
Ok(text)
}
/// A line turned a quarter from the page's main text, read in place among it.
struct InPlace {
/// Where laying it out with the main text puts its first glyph.
at: usize,
ids: Vec<usize>,
space_before: Vec<bool>,
/// The quarter turn of the flow it came from.
quarter: Option<u8>,
}
/// Takes out of `flows` the lines that are turned a quarter from the page's main text (the unturned flow, when it
/// holds more glyphs than any other) and sit inside that text's area: a label set upwards beside a row, a turned
/// table heading. Each is placed where laying it out among the main text, by its box on the page, puts its first
/// glyph, rather than after all the main text. Lines outside the area (a stamp in the margin) and every other flow
/// keep their place, as does a page whose main text is vertical.
///
/// Returns the main flow's index, the lines in place order, and each glyph's place in that layout.
fn turned_lines_in_place(glyphs: &[Drawn], flows: &mut [Flow]) -> (Option<usize>, std::iter::Peekable<std::vec::IntoIter<InPlace>>, Vec<usize>) {
let nothing = || (None, Vec::new().into_iter().peekable(), Vec::new());
let Some((main, size)) = flows.iter().enumerate().find(|(_, f)| f.quarter == Some(0)).map(|(i, f)| (i, f.ids.len())) else { return nothing() };
if flows.iter().enumerate().any(|(i, f)| i != main && f.ids.len() >= size) {
return nothing();
}
let rect = |i: usize| glyphs.get(i).map_or([0.0; 4], |g| g.2.rect);
let bounds = |ids: &[usize]| {
let mut b = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for &i in ids {
union(&mut b, &rect(i));
}
b
};
let Some(area) = flows.get(main).map(|f| bounds(&f.ids)) else { return nothing() };
let mut lines = Vec::new();
for (fi, flow) in flows.iter_mut().enumerate() {
// Upside-down text (a half turn) is not turned a quarter, and keeps its own flow.
if fi == main || !matches!(flow.quarter, Some(1 | 3)) {
continue;
}
let (mut ids, mut line_of, mut space_before) = (Vec::new(), Vec::new(), Vec::new());
let mut k = 0;
while let Some(&line) = flow.line_of.get(k) {
let end = flow.line_of.get(k..).and_then(|rest| rest.iter().position(|l| *l != line)).map_or(flow.line_of.len(), |n| k + n);
let (Some(line_ids), Some(line_spaces)) = (flow.ids.get(k..end), flow.space_before.get(k..end)) else { break };
let b = bounds(line_ids);
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
if (area[0]..=area[2]).contains(&cx) && (area[1]..=area[3]).contains(&cy) {
lines.push(InPlace { at: usize::MAX, ids: line_ids.to_vec(), space_before: line_spaces.to_vec(), quarter: flow.quarter });
} else {
ids.extend_from_slice(line_ids);
line_of.extend(std::iter::repeat_n(line, line_ids.len()));
space_before.extend_from_slice(line_spaces);
}
k = end;
}
(flow.ids, flow.line_of, flow.space_before) = (ids, line_of, space_before);
}
if lines.is_empty() {
return nothing();
}
let ids: Vec<usize> =
flows.get(main).map_or(&[][..], |f| &f.ids[..]).iter().copied().chain(lines.iter().flat_map(|l| l.ids.iter().copied())).collect();
let rects = ids.iter().map(|&i| rect(i)).collect();
let mut place = vec![usize::MAX; glyphs.len()];
for (r, i) in arrange_flow(glyphs, ids, rects, Some(0)).ids.into_iter().enumerate() {
if let Some(p) = place.get_mut(i) {
*p = r;
}
}
for l in &mut lines {
l.at = l.ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX);
}
lines.sort_by_key(|l| l.at);
(Some(main), lines.into_iter().peekable(), place)
}
+51 -6
View File
@@ -1,6 +1,7 @@
use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP};
use super::order::{
NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, reverse_rtl,
NONE, REDRAW_CANDIDATES, Run, SAME_LINE_GAP, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run,
reverse_rtl, same_line,
};
use super::{HashMap, PageText, TextGlyph};
@@ -183,7 +184,11 @@ pub(super) fn union(a: &mut [f32; 4], b: &[f32; 4]) {
/// (text on a baseline grid), it counts as a row and the top-most rule applies, so the column
/// order of #3658 is lost there. Telling prose from a row in that case needs more evidence.
/// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the
/// text comes out in logical order.
/// text comes out in logical order. A block whose first line sits on the baseline of the line read
/// just before it, to its right and less than [`SAME_LINE_GAP`] heights away, continues that line
/// after a space (a label and its value, words drawn out of order or in another size). A block of
/// several lines does so only after a one-line block narrower than [`SAME_LINE_GAP`] heights (a
/// label): beside a wider one, or the last line of a column, it is the next column.
pub fn layout(glyphs: Vec<TextGlyph>) -> PageText {
let (order, line_of, space_before) = arrange(&glyphs);
let mut slots: Vec<Option<TextGlyph>> = glyphs.into_iter().map(Some).collect();
@@ -456,8 +461,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
// `spaces[w]`: a word gap left of the w-th glyph of the segment, by position on the line, not by drawing order
// (#1062). Reused by every segment.
let mut spaces: Vec<bool> = Vec::new();
// The segment emitted last, whether it had right-to-left text, and whether its block is that one line.
let mut last: Option<(usize, bool, bool)> = None;
for b in block_order {
for &si in &blocks[b].0 {
for (k, &si) in blocks[b].0.iter().enumerate() {
place_glyphs(&glyphs, &height, &segs[si], &mut sc);
let h = segs[si].h;
measure_cluster_gaps(&glyphs, &mut sc);
@@ -512,13 +519,51 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
reverse_rtl(&glyphs, &sc.anchor, &mut sc.logical, &mut spaces);
}
let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed };
let joined = joins_line(&segs, last, si, k == 0, blocks[b].0.len() > 1, has_rtl);
let start = order.len();
order.extend_from_slice(idx);
line_of.resize(order.len(), line);
// `spaces[0]` stays false: the first glyph of a segment never takes a space.
// `spaces[0]` stays false: the first glyph of a segment never takes a space, unless it joins a line.
space_before.extend(spaces.iter().take(idx.len()));
space_before.resize(order.len(), false);
line += 1;
line = put_on_line(&mut line_of, &mut space_before, start..order.len(), line, &segs[si], joined);
last = Some((si, has_rtl, blocks[b].0.len() == 1));
}
}
(order.into_iter().map(|i| kept[i]).collect(), line_of, space_before)
}
/// Puts the reading-order glyphs `glyphs` of segment `seg` on their line: `line`, or the line
/// before it when `seg` continues the line of `joined` (see [`joins_line`]), with a space before
/// its first glyph when the two are a word gap apart. Returns the line the next segment starts on.
fn put_on_line(line_of: &mut Vec<u32>, space_before: &mut [bool], glyphs: std::ops::Range<usize>, line: u32, seg: &Seg, joined: Option<&Seg>) -> u32 {
let Some(p) = joined else {
line_of.resize(glyphs.end, line);
return line.saturating_add(1);
};
line_of.resize(glyphs.end, line.saturating_sub(1));
if let Some(space) = space_before.get_mut(glyphs.start) {
*space = seg.bbox[0] - p.bbox[2] > WORD_GAP * p.h.min(seg.h);
}
line
}
/// The segment read just before (`last`, with whether it had right-to-left text) whose line segment
/// `si` goes on, when `si` is the first line of its block (`first`) and sits on that line's baseline
/// to its right (a label and its value, a word drawn out of order or set in another size).
/// Same-baseline segments are always in different blocks. A column of several lines is read after
/// the column to its left ends, so their lines don't meet. A paragraph of several lines that starts
/// beside a line as wide as [`SAME_LINE_GAP`] heights or more is the next column, not that line's
/// value, so it starts a line of its own; only a narrower line that is a block of its own (a label)
/// takes it: the short last line of a column of several lines is no label (review on #4468). One-line
/// paragraphs of two columns that share a baseline cannot be told from a table row, and are further
/// apart than [`same_line`] allows unless the left one fills its column. Right-to-left lines are
/// left alone: their blocks still read left first.
fn joins_line(segs: &[Seg], last: Option<(usize, bool, bool)>, si: usize, first: bool, paragraph: bool, has_rtl: bool) -> Option<&Seg> {
let (p, p_rtl, p_alone) = last?;
if !first || p_rtl || has_rtl {
return None;
}
let (p, s) = (segs.get(p)?, segs.get(si)?);
let label = p_alone && p.bbox[2] - p.bbox[0] < SAME_LINE_GAP * p.h;
(same_line(p, s) && (!paragraph || label)).then_some(p)
}
+17
View File
@@ -1,4 +1,5 @@
use super::TextGlyph;
use super::device::WORD_GAP;
use super::layout::is_rtl;
/// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another.
@@ -765,3 +766,19 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [us
}
}
}
/// Most space between two runs on one baseline that still reads as one line, in heights of the
/// taller run: a label and its value or table cells, and not two column lines that happen to share
/// a baseline (a single-line paragraph in each column), whose gap spans most of a column.
pub(super) const SAME_LINE_GAP: f32 = 8.0;
/// Whether segment `s` continues the line of segment `p` to its right: their baselines (a fifth of
/// the em box above its bottom, as `draw_glyph` builds it) lie within a fifth of the smaller
/// height, whatever their sizes, and `s` starts no earlier than `p` ends and less than
/// [`SAME_LINE_GAP`] heights after it.
pub(super) fn same_line(p: &Seg, s: &Seg) -> bool {
let h = p.h.min(s.h);
let baseline = |seg: &Seg| seg.bbox[3] - 0.2 * seg.h;
let gap = s.bbox[0] - p.bbox[2];
(baseline(p) - baseline(s)).abs() < 0.2 * h && gap > -WORD_GAP * h && gap < SAME_LINE_GAP * p.h.max(s.h)
}
+220 -3
View File
@@ -1396,6 +1396,197 @@ fn two_columns_read_left_then_right_even_if_drawn_interleaved() {
assert_eq!(t.plain_text(), "Left1\nLeft2\nRight1\nRight2");
}
/// Runs on one baseline read as one line, left to right, with a space at the gap between them, however they were
/// drawn and whatever their sizes. Columns of several lines still read one after the other.
#[test]
fn runs_on_one_baseline_read_as_one_line_in_any_drawing_order() {
// Glyphs of `h` pt on the baseline `base`, 0.6 em apart.
let sized = |v: &mut Vec<TextGlyph>, s: &str, x: f32, base: f32, h: f32| {
for (i, c) in s.chars().enumerate() {
let x0 = x + i as f32 * h * 0.6;
v.push(em_box(&c.to_string(), x0, base - 0.8 * h, x0 + h * 0.6, base + 0.2 * h));
}
};
// The second word drawn first, three and a half heights to the right of the first.
let mut v = Vec::new();
sized(&mut v, "second", 160.0, 100.0, 14.0);
sized(&mut v, "first", 90.0, 100.0, 14.0);
let t = layout(v);
assert_eq!(t.plain_text(), "first second");
assert_eq!(t.line_rects(0..t.glyphs.len()).len(), 1, "one line, one highlight");
// A 9 pt label, a 14 pt word and a 9 pt note on one baseline.
let mut v = Vec::new();
sized(&mut v, "11", 40.0, 100.0, 9.0);
sized(&mut v, "hidden", 90.0, 100.0, 14.0);
sized(&mut v, "note", 170.0, 100.0, 9.0);
assert_eq!(layout(v).plain_text(), "11 hidden note");
// A label beside two columns of two lines: the label joins the first line of the left column, and the
// columns keep their own lines.
let mut v = Vec::new();
sized(&mut v, "07", 40.0, 100.0, 9.0);
sized(&mut v, "leftone", 90.0, 100.0, 12.0);
sized(&mut v, "lefttwo", 90.0, 114.0, 12.0);
sized(&mut v, "rightone", 300.0, 100.0, 12.0);
sized(&mut v, "righttwo", 300.0, 114.0, 12.0);
assert_eq!(layout(v).plain_text(), "07 leftone\nlefttwo\nrightone\nrighttwo");
// Two columns of prose 20 pt apart, where a one-line paragraph that fills the left column shares its baseline
// with the first line of a paragraph in the right one: the paragraph is the next column, not the line's value.
let mut v = Vec::new();
for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() {
sized(&mut v, &format!("leftparagraphlinenumber{k}xxxxxxxxxxxxxxx"), 72.0, *y, 10.0);
}
sized(&mut v, "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx", 72.0, 160.0, 10.0);
for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() {
sized(&mut v, &format!("rightparagraphline{k}"), 326.0, *y, 10.0);
}
sized(&mut v, "rightsecondpara", 326.0, 160.0, 10.0);
sized(&mut v, "rightsecondparaline2", 326.0, 172.0, 10.0);
let t = layout(v).plain_text();
assert!(t.lines().any(|l| l == "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx"), "{t}");
assert!(t.lines().any(|l| l == "rightsecondpara"), "{t}");
// Staggered columns (review on #4468): the left column's short last line shares its baseline with
// the right column's first line. That last line is no label, so the columns keep their lines.
let mut v = Vec::new();
sized(&mut v, "leftcol1", 10.0, 100.0, 10.0);
sized(&mut v, "leftcol2", 10.0, 112.0, 10.0);
sized(&mut v, "end", 10.0, 124.0, 10.0);
sized(&mut v, "rightone", 82.0, 124.0, 10.0);
sized(&mut v, "righttwo", 82.0, 136.0, 10.0);
assert_eq!(layout(v).plain_text(), "leftcol1\nleftcol2\nend\nrightone\nrighttwo");
// Half a height lower is another line.
let mut v = Vec::new();
sized(&mut v, "first", 90.0, 100.0, 14.0);
sized(&mut v, "lower", 160.0, 107.0, 14.0);
assert_eq!(layout(v).plain_text(), "first\nlower");
// Right-to-left runs on one baseline keep their lines: their blocks read left first.
let mut v = Vec::new();
sized(&mut v, "שלום", 90.0, 100.0, 14.0);
sized(&mut v, "עולם", 200.0, 100.0, 14.0);
assert_eq!(layout(v).plain_text().lines().count(), 2);
}
/// A page of fourteen labelled rows, each a case of text copy: a `/fi` glyph by name, a ToUnicode map, an
/// `/ActualText` span, word gaps by `TJ` and `Td`, a hyphen at a line end, two columns, words drawn out of order,
/// fake bold, invisible text, letter spacing and a line turned upwards.
fn copy_test_page() -> Pdf {
use lopdf::{Document, Object, Stream, dictionary};
let rows: [(&str, &str); 14] = [
("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"),
("02", "BT /F3 14 Tf 90 {Y} Td (XEY) Tj ET"),
("03", "/Span << /ActualText (replaced) >> BDC BT /F1 14 Tf 90 {Y} Td (shown) Tj ET EMC"),
("04", "BT /F1 14 Tf 90 {Y} Td [(Hello) -400 (world)] TJ ET"),
("05", "BT /F1 14 Tf 90 {Y} Td [(W) 80 (ave)] TJ ET"),
("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"),
("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"),
("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"),
("09", "BT /F1 14 Tf 90 {Y} Td (caf\\351 na\\357ve) Tj ET"),
("10", "BT /F1 14 Tf 90 {Y} Td (Bold) Tj ET BT /F1 14 Tf 90.4 {Y} Td (Bold) Tj ET"),
("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"),
("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"),
("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"),
("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"),
];
let mut content = String::from("BT /F1 9 Tf 40 770 Td (Text copy and search test) Tj ET\n");
let mut y = 740;
for (tag, body) in rows {
content += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", body.replace("{Y}", &y.to_string()));
y -= 38
+ match tag {
"06" | "13" => 10,
"07" => 6,
_ => 0,
};
}
let mut doc = Document::with_version("1.7");
let helvetica = || dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => "WinAnsiEncoding" };
let f1 = doc.add_object(helvetica());
let differences = dictionary! { "Type" => "Encoding", "BaseEncoding" => "WinAnsiEncoding", "Differences" => vec![Object::Integer(128), Object::Name(b"fi".to_vec())] };
let f2 = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => differences });
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 3 beginbfchar <58> <004B> <45> <0045> <59> <0059> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.as_bytes().to_vec()));
let mut f3 = helvetica();
f3.set("ToUnicode", to_unicode);
let f3 = doc.add_object(f3);
let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! {
"Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => f1, "F2" => f2, "F3" => f3 } }, "Contents" => contents
});
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
Pdf::new(std::sync::Arc::new(bytes)).unwrap()
}
/// Copy of the whole page: one line per baseline in left-to-right order whatever the drawing order, the `/fi`
/// glyph as its letters, and the line turned upwards in its row, not after the page. `/ActualText` replaces what
/// is drawn (ISO 32000-2 §14.9.4), a hard hyphen at a line end stays (§14.8.2.3: an incidental one is U+00AD),
/// fake bold reads once, and letter spacing that is the same between every letter is tracking, not word gaps.
#[test]
fn a_copied_page_reads_one_line_per_baseline_in_reading_order() {
let text = extract_page(&copy_test_page(), 0, &InterpreterSettings::default()).unwrap();
assert_eq!(
text.plain_text(),
"Text copy and search test\n01 office\n02 KEY\n03 replaced\n04 Hello world\n05 Wave\n06 exam-\nple\n07 left one\nleft two\n\
right one\nright two\n08 first second\n09 café naïve\n10 Bold\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words"
);
assert_eq!(text.find("first second").len(), 1);
assert_eq!(text.find("office").len(), 1);
}
/// A turned line inside the page's text reads where it stands; one in the margin, beside every line (a stamp
/// set up the edge of the page), still reads after the text instead of splitting a paragraph.
#[test]
fn a_turned_line_reads_in_place_inside_the_text_and_after_it_in_the_margin() {
use lopdf::{Document, Object, Stream, dictionary};
let page = |before: &str, after: &str| {
let mut doc = Document::with_version("1.7");
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
let mut content = before.to_owned();
for (k, word) in ["Alpha", "Beta", "Gamma", "Delta"].iter().enumerate() {
content += &format!("BT /F1 12 Tf 72 {} Td ({word}) Tj ET\n", 700 - 30 * k);
}
content += after;
let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes()));
let pages = doc.new_object_id();
let page = doc.add_object(dictionary! {
"Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => contents
});
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
doc.trailer.set("Root", catalog);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).unwrap();
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
};
// "up", set upwards from the baseline of "Beta" beside it, and "dn", set downwards in the space under it.
let up = "BT /F1 12 Tf 0 1 -1 0 110 670 Tm (up) Tj ET\n";
let down = "BT /F1 8 Tf 0 -1 1 0 100 662 Tm (dn) Tj ET\n";
assert_eq!(page("", up), "Alpha\nBeta\nup\nGamma\nDelta");
assert_eq!(page(up, ""), "Alpha\nBeta\nup\nGamma\nDelta", "drawn before the text");
assert_eq!(page("", down), "Alpha\nBeta\ndn\nGamma\nDelta");
// Upside-down text inside the text's area, under "Beta", is not turned a quarter and keeps its own flow.
let upside_down = "BT /F1 12 Tf -1 0 0 -1 110 660 Tm (ud) Tj ET\n";
assert_eq!(page("", upside_down), "Alpha\nBeta\nGamma\nDelta\nud");
// A stamp up the left margin, beside "Beta" and "Gamma": laid out with the text, it would read after "Alpha".
let stamp = "BT /F1 10 Tf 0 1 -1 0 30 640 Tm (arXiv stamp) Tj ET\n";
assert_eq!(page("", stamp), "Alpha\nBeta\nGamma\nDelta\narXiv stamp");
}
#[test]
fn ligature_characters_read_as_their_letters() {
assert_eq!(ligature_letters("o\u{FB01}ce".into()), "ofice");
assert_eq!(ligature_letters("\u{FB00}\u{FB02}\u{FB03}\u{FB04}\u{FB05}\u{FB06}".into()), "ffflffifflstst");
// Only those seven: other compatibility characters stay as they are.
assert_eq!(ligature_letters("\u{FB13}\u{FF21}\u{2460}é".into()), "\u{FB13}\u{FF21}\u{2460}é");
assert_eq!(ligature_letters(String::new()), "");
}
/// Lays out one-word lines, each `(text, x, y)`, 10 pt high: 14 pt apart is a line in a paragraph, 24 pt a
/// paragraph gap.
fn lines(at: &[(&str, f32, f32)]) -> String {
@@ -1493,7 +1684,32 @@ fn a_full_width_line_ends_the_columns_above_it() {
fn table_rows_apart_still_read_across() {
// Rows a paragraph gap apart whose cells start level read row by row, as before #3658.
let rows = [("Name", 10.0, 10.0), ("Qty", 100.0, 10.0), ("Apple", 10.0, 34.0), ("12", 100.0, 34.0), ("Pear", 10.0, 58.0), ("7", 100.0, 58.0)];
assert_eq!(lines(&rows), "Name\nQty\nApple\n12\nPear\n7");
// The cells of a row share its baseline, so they share a line too.
assert_eq!(lines(&rows), "Name Qty\nApple 12\nPear 7");
// Column select still separates them with a tab.
let mut v = Vec::new();
for &(s, x, y) in &rows {
word(&mut v, s, x, y, 6.0);
}
let t = layout(v);
assert_eq!(t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0])), "Name\tQty\nApple\t12\nPear\t7");
}
/// Two cells two heights apart (closer than [`CELL_GAP`]) copy the same whichever is drawn first:
/// drawn left to right they were always one run, and the right cell drawn first now joins it too.
#[test]
fn close_cells_copy_the_same_in_either_drawing_order() {
let copy = |cells: &[(&str, f32)]| {
let mut v = Vec::new();
for &(s, x) in cells {
word(&mut v, s, x, 10.0, 6.0);
}
let t = layout(v);
(t.plain_text(), t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0])))
};
let in_order = copy(&[("Name", 10.0), ("Qty", 54.0)]);
assert_eq!(copy(&[("Qty", 54.0), ("Name", 10.0)]), in_order);
assert_eq!(in_order.0, "Name Qty");
}
#[test]
@@ -1512,7 +1728,7 @@ fn form_rows_in_mixed_sizes_still_read_across() {
sized(&mut v, label, 10.0, base, 8.0);
sized(&mut v, value, 100.0, base, 12.0);
}
assert_eq!(layout(v).plain_text(), "Name\nAlice\nStreet\nMain\nCity\nOslo");
assert_eq!(layout(v).plain_text(), "Name Alice\nStreet Main\nCity Oslo");
// Values drawn 6 pt below their labels, rows a paragraph gap apart.
let offset =
[("Name", 10.0, 10.0), ("Val1", 100.0, 16.0), ("Addr", 10.0, 40.0), ("Val2", 100.0, 46.0), ("City", 10.0, 70.0), ("Val3", 100.0, 76.0)];
@@ -1521,7 +1737,8 @@ fn form_rows_in_mixed_sizes_still_read_across() {
#[test]
fn a_column_does_not_jump_to_a_distant_footer_footnote_or_caption() {
// A page number far below the left column reads after the right column.
// A page number far below the left column reads after the right column. L3 and R3, one-line paragraphs
// on one baseline a column apart, keep their own lines.
let footer = [
("L1", 10.0, 10.0),
("L2", 10.0, 24.0),
+2 -2
View File
@@ -1550,8 +1550,8 @@ milestone = "M3"
status = "shipped"
commands = ["edit.copy"]
tools = ["text_extract"]
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it", "crates/render/src/text/tests.rs::issue_11656_layout_reads_whole_words", "crates/render/src/text/tests.rs::combining_marks_of_other_scripts_keep_their_place"]
notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. Copied Thai, Devanagari and Latin text keeps each combining mark after its letter when every glyph is drawn on its own (#6517). A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it, also when a mark is drawn between them; punctuation taken back that way moves only inside right-to-left text."
evidence = ["crates/ui-egui/tests/it/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership", "crates/render/src/text/tests.rs::a_space_taken_back_under_the_next_letter_stays_before_it", "crates/render/src/text/tests.rs::issue_11656_layout_reads_whole_words", "crates/render/src/text/tests.rs::combining_marks_of_other_scripts_keep_their_place", "crates/render/src/text/tests.rs::a_copied_page_reads_one_line_per_baseline_in_reading_order", "crates/render/src/text/tests.rs::runs_on_one_baseline_read_as_one_line_in_any_drawing_order", "crates/render/src/text/tests.rs::a_turned_line_reads_in_place_inside_the_text_and_after_it_in_the_margin", "crates/automation/tests/it/text_copy.rs::text_extract_and_find_read_one_line_per_baseline"]
notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract. Copied Thai, Devanagari and Latin text keeps each combining mark after its letter when every glyph is drawn on its own (#6517). A space that starts a right-to-left show string and is taken back under the next letter (ISO 32000-2 §14.8.2.5.3) stays a word break after its word instead of splitting it, also when a mark is drawn between them; punctuation taken back that way moves only inside right-to-left text. Copied text keeps runs on one baseline on one line, left to right with a space, whatever order they were drawn in (columns, and runs more than 8 heights apart, keep their own lines); a line turned a quarter inside the text reads in its place (one in the margin still reads last); the Latin ligature characters U+FB00–FB06 copy as their letters; /ActualText replaces the glyphs it marks and a hard hyphen at a line end is kept (ISO 32000-2 §14.9.4, §14.8.2.3). Missing: letter-spaced (Tc) text whose gaps are word-sized copies without spaces (tracking is told from word gaps by the line's typical gap); right-to-left runs on one baseline stay on separate lines."
[[feature]]
id = "view.select-all"