diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index eb80c02c39..ec235c9d7e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1903,6 +1903,30 @@ fn tracked_text_preserves_larger_word_gaps() { } } +/// LibreOffice stretches a justified line on both sides of each space glyph, so a gap that reads +/// as a space comes right before the space glyph it widens: one space, not two. A gap after the +/// space glyph, a space glyph with no gap before it, and two space glyphs after a gap read as +/// before. +#[test] +fn a_gap_before_a_space_glyph_reads_as_one_space() { + for show in ["[(Word) -250 ( next) -250 ( last)] TJ", "[(Word ) -250 (next ) -250 (last)] TJ", "[(Word) -250 (next) 0 ( last)] TJ"] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last", "{show}"); + } + // Only the first space glyph after the gap is the gap's: two real spaces stay two. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250 ( next)] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // The same line split over operators: the gap ends one run, its space glyph starts the next. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250] TJ [( next) -250] TJ ( last) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last"); + // Space glyphs on both sides of a break between runs, with no gap, are two spaces. + let doc = text_page("BT /F1 10 Tf 72 700 Td (Word ) Tj ( next) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // Down a vertical column the same. + let doc = vertical_page("BT /V1 20 Tf 500 720 Td [<0041> 300 <00200042> 300 <00200043>] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "A B C"); +} + #[test] fn tracked_text_positions_do_not_count_character_spacing_twice() { let expected = "TRACKED SAMPLE TEXT"; @@ -9742,7 +9766,7 @@ fn vertical_page(content: &str) -> Document { ]); let unicode = doc.add(Object::Stream(Stream::flate( Dict::new(), - b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + b"1 begincodespacerange <0000> endcodespacerange 7 beginbfchar <0020> <0020> <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", ))); font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); font.set( diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 019edf982a..bc0bce0c7b 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -493,6 +493,9 @@ pub(super) struct Carry { pub(super) layers: Vec>>, /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). pub(super) text_left: usize, + /// Whether the last run ended in a space read from a gap, with the next operator a `Tj` or + /// `TJ` that may start with the space glyph that gap widened. + pub(super) gap_space: bool, } /// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is @@ -523,7 +526,7 @@ impl Carry { dash: (Vec::new(), 0.0), line_cap: 0, }; - Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT, gap_space: false } } } @@ -661,6 +664,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // the next glyph continues the last word. let mut words: Vec<(f64, f64)> = Vec::new(); let mut in_word = false; + // Whether the text ends in a space read from a gap rather than from a glyph. The run just + // before may have ended in one: a justified line can be split over `TJ` and `Tj`. + let mut gap_space = std::mem::take(&mut carry.gap_space); let mut glyph_count = 0_usize; for p in &pieces { match p { @@ -669,13 +675,17 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit glyph_count = glyph_count.saturating_add(1); let shown = m.text_of(code); let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + // A space glyph right after a gap that read as a space is that space: + // LibreOffice stretches a justified line on both sides of each space glyph. + let after_gap = std::mem::take(&mut gap_space); match shown { - Some(t) => { + Some(shown) => { + let t: &str = if after_gap { shown.strip_prefix(' ').unwrap_or(&shown) } else { &shown }; carry.text_left = carry .text_left .checked_sub(t.len()) .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); + text.push_str(t); } None => decodable = false, } @@ -716,6 +726,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit let dy = -n / 1000.0 * ts.size; if -dy > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } if dy.abs() > ts.size.abs() * 0.2 { in_word = false; @@ -727,6 +738,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // A large gap inside TJ reads as a space. if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } // Tracking and kerning keep a word together; a larger jump // ends it (whatever the sign of the size or `Tz`). @@ -821,6 +833,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_gap: state.soft_gap, edited: marked.edited.last().copied().flatten(), }); + carry.gap_space = gap_space; Ok(()) } @@ -854,6 +867,10 @@ pub(super) fn interpret( // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { + // A gap's space carries only into a `Tj` or `TJ` right after it. + if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { + carry.gap_space = false; + } match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 54c249dbf1..8d0ad80cba 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -705,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec