Text from a justified line keeps one space between words (#5496)

This commit is contained in:
oracola91
2026-11-09 18:31:47 +08:00
committed by GitHub
parent 12a9b23394
commit 476effc1c8
3 changed files with 53 additions and 5 deletions
+25 -1
View File
@@ -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> <ffff> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar",
b"1 begincodespacerange <0000> <ffff> 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(
+20 -3
View File
@@ -493,6 +493,9 @@ pub(super) struct Carry {
pub(super) layers: Vec<Option<Vec<u8>>>,
/// 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.
+8 -1
View File
@@ -705,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
let mut carry = Carry {
ts: Ts { ctm: form.ctm, ..ts },
stack: Vec::new(),
marked: Vec::new(),
layers: Vec::new(),
text_left: *text_left,
gap_space: false,
};
let stream = w.next_stream;
w.next_stream = w.next_stream.saturating_add(1);
if let Some(r) = form.obj {