mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 15:26:14 +00:00
M3: copied text keeps one line per baseline, reading order for turned lines, letters for ligatures (#4467) (#4468)
This commit is contained in:
@@ -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"] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(©_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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user