mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 16:36:34 +00:00
Text from a justified line keeps one space between words (#5496)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user