edit: preserve gap-drawn spaces and indents in subset fonts via /ActualText (#4229) (#4514)

Co-authored-by: jonathanwxh-cell <jonathanwxh-cell@users.noreply.github.com>
This commit is contained in:
jonathanwxh-cellandjonathanwxh-cell authored and GitHub committed 2026-11-09 18:13:34 +08:00
1 parent 7fa6f10720
commit ab502255cb
6 files changed
+308 -56

No files matched your search

+133 -1
View File
@@ -11026,8 +11026,140 @@ fn carriage_returns_break_rows_on_the_styled_path() {
fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
let reopened = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned();
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, "ab ba ab");
}
/// Spaces drawn as TJ gaps in a subset without a space glyph are wrapped in `/ActualText`, so
/// repeated spaces and leading indents read back through `text_lines`, `editor_text`, and text
/// extraction, and survive a second edit without shifting the line or miscalibrating its scale
/// (#4229).
#[test]
fn spaces_and_indents_in_a_subset_without_a_space_glyph_round_trip_across_edits() {
for paragraph in [true, false] {
for typed in [" ab ba", "ab ba ab", " ab ba ab "] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET");
let edit = if paragraph { text::replace_block(&mut doc, 0, 0, typed) } else { text::replace_line(&mut doc, 0, 0, typed) };
assert_eq!(edit.unwrap().substituted, None, "{typed:?}");
let mut doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.len(), 1, "{typed:?}: {lines:?}");
assert_eq!(lines[0].text, typed, "{typed:?}");
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks[0].editor_text, typed, "{typed:?}");
// Re-edit the reopened document and verify the origin, gap count, and scale stay exact.
let next = " ba ab";
let edit2 = if paragraph { text::replace_block(&mut doc, 0, 0, next) } else { text::replace_line(&mut doc, 0, 0, next) };
assert_eq!(edit2.unwrap().substituted, None, "{typed:?}");
let doc = reopen(&doc);
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines[0].text, next, "{typed:?}");
assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
}
}
// Styled multi-row path: untouched calibration and row breaks keep 100 Tz and every space.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
let mut doc = reopen(&doc);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None);
let doc = reopen(&doc);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect();
assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, [" ab ba", "ba ab"]);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab");
// Two subsets of the same face (/F2 holds 'a' only, /F3 holds 'b' only, neither has ' '):
// spaces adopt their neighbour's subset on the row so no space is stranded in a glyph-less run.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (a) Tj /F1 12 Tf ( ) Tj /F3 12 Tf (b) Tj ET");
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
f.set(b"FirstChar".to_vec(), Object::Int(97));
f.set(b"LastChar".to_vec(), Object::Int(97));
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(500)]));
})
.unwrap();
doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |f| {
f.set(b"FirstChar".to_vec(), Object::Int(98));
f.set(b"LastChar".to_vec(), Object::Int(98));
f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(520)]));
})
.unwrap();
assert_eq!(text::replace_block(&mut doc, 0, 0, " b a \n a b ").unwrap().substituted, None);
let doc = reopen(&doc);
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, [" b a ", " a b "]);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " b a \n a b ");
// A RangeStyle covering only spaces in a subset without a space glyph joins an adjacent
// glyph-bearing segment so its space count survives save/reopen.
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let style = text::BlockStyle {
ranges: vec![text::RangeStyle {
range: 2..5,
family: None,
resource: None,
size: None,
color: Some([1.0, 0.0, 0.0]),
underline: None,
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
let doc = reopen(&doc);
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "ab ba");
}
/// A space that joins a neighbour's subset for `/ActualText` keeps the underline of its own
/// style, not the neighbour's, and still reads back as that space.
#[test]
fn a_reassigned_subset_space_keeps_its_own_underline() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let style = text::BlockStyle {
ranges: vec![text::RangeStyle {
range: 2..5,
family: None,
resource: None,
size: None,
color: None,
underline: Some(true),
char_spacing: None,
scale: None,
}],
..Default::default()
};
assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None);
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().into_iter().next().expect("a line");
assert_eq!(line.text, "ab ba");
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let mut widths = Vec::new();
let mut from = None;
for row in content.lines() {
if let Some(rest) = row.strip_suffix(" m") {
from = rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok());
} else if let Some(rest) = row.strip_suffix(" l")
&& let (Some(x0), Some(x1)) = (from.take(), rest.split_whitespace().next().and_then(|s| s.parse::<f64>().ok()))
{
widths.push((x1 - x0).abs());
}
}
let text_width = line.rect[2] - line.rect[0];
assert!(widths.iter().any(|w| *w > 1.0), "the spaces lost their underline: {widths:?}\n{content}");
assert!(
widths.iter().all(|w| *w < text_width * 0.6),
"the space underline covered the neighbouring glyphs ({text_width}): {widths:?}\n{content}"
);
}
/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to
+7
View File
@@ -720,6 +720,13 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64,
out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
}
}
if out.iter().any(|op| op.is("Tj")) {
let logical = crate::bidi::to_logical(&if metrics.visual_order { crate::indic::to_logical(text) } else { text.to_owned() });
let mut properties = Dict::new();
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&logical)));
out.insert(0, Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
out.push(Op::new("EMC", Vec::new()));
}
Ok(out)
}
+33 -27
View File
@@ -567,32 +567,34 @@ impl MarkedContent {
self.layers.pop();
if let Some(Some((start, open, logical))) = self.actual_text.pop() {
let tail = shown.split_off(start.min(shown.len()));
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
if tail.iter().any(|s| s.glyph_count > 0) {
let mut items = tail.into_iter();
if let Some(mut first) = items.next() {
for item in items {
first.glyph_count = first.glyph_count.saturating_add(item.glyph_count);
first.extra_ops.push(item.op);
first.extra_ops.extend(item.extra_ops);
first.rect = [
first.rect[0].min(item.rect[0]),
first.rect[1].min(item.rect[1]),
first.rect[2].max(item.rect[2]),
first.rect[3].max(item.rect[3]),
];
first.frame = union(first.frame, item.frame);
first.ink.extend(item.ink);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
first.extra_ops.extend([open, index]);
// Only right-to-left ActualText is already in typing order and must stay out of
// bidi recovery. Word and Chrome also wrap Latin ligatures mid-word.
first.logical_order = logical.chars().any(crate::bidi::is_rtl);
first.actual_text = true;
first.text = logical;
first.start_x = first.rect[0];
first.end_x = first.rect[2];
first.end_y = first.baseline;
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
shown.push(first);
}
}
}
@@ -761,8 +763,11 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
.collect();
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
// It must not create a phantom editable line or enlarge its neighbours' boxes.
if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
// Outside an `/ActualText` replacement it must not create a phantom editable
// line or enlarge its neighbours' boxes; inside one (such as a space gap in a
// subset font without a space glyph, #4229) it is part of the span's box and
// operators when the span also paints glyphs.
if !marked.actual_text.iter().any(Option::is_some) && !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) {
return Ok(());
}
out.push(Shown {
@@ -1006,6 +1011,7 @@ pub(super) fn interpret(
carry.stack = stack;
carry.marked = marked.regions;
carry.layers = marked.layers;
out.retain(|s| s.glyph_count > 0 || s.actual_text);
positioned_tracking(&mut out);
Ok(out)
}
+11 -6
View File
@@ -649,12 +649,17 @@ impl LineWriter<'_> {
}
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
let visual = crate::bidi::to_visual(shown);
// Stretched without word spacing: shown up to and including each space, then moved on.
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
for piece in pieces {
self.show_piece(piece, line, tw, cursor, ops)?;
if stretch > 0.0 && piece.ends_with(' ') {
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) {
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?);
} else {
// Stretched without word spacing: shown up to and including each space, then moved on.
let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
for piece in pieces {
self.show_piece(piece, line, tw, cursor, ops)?;
if stretch > 0.0 && piece.ends_with(' ') {
ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])]));
}
}
}
let tw = tw + stretch;
+117 -21
View File
@@ -354,6 +354,17 @@ struct StyledLines<'a> {
}
type StyledChar = (char, usize, String);
/// End of a contiguous run sharing both its style and font resource.
fn styled_run_end(items: &[StyledChar], start: usize) -> usize {
let Some(first) = items.get(start) else { return start };
let mut end = start + 1;
while items.get(end).is_some_and(|next| next.1 == first.1 && next.2 == first.2) {
end += 1;
}
end
}
type StyledRows = Vec<Vec<StyledChar>>;
type RowRanges = Vec<std::ops::Range<usize>>;
enum ShapedFont {
@@ -370,6 +381,7 @@ type ShapedRunCache = HashMap<(usize, usize), ShapedText>;
struct ShapedRows {
runs: ShapedRunCache,
advances: HashMap<(usize, usize), f64>,
char_advances: HashMap<(usize, usize), Vec<f64>>,
}
fn prepare_shaped_rows(
@@ -382,15 +394,15 @@ fn prepare_shaped_rows(
) -> Result<ShapedRows, EditError> {
let mut runs = HashMap::new();
let mut advances = HashMap::new();
let mut char_advances = HashMap::new();
for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) {
let mut at = 0;
while let Some(item) = row.get(at) {
let mut end = at + 1;
while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) {
end += 1;
}
let end = styled_run_end(row, at);
let segment = row.get(at..end).unwrap_or_default();
advances.insert((row_index, at), segment.iter().map(&advance).sum::<f64>());
let widths: Vec<f64> = segment.iter().map(&advance).collect();
advances.insert((row_index, at), widths.iter().copied().sum());
char_advances.insert((row_index, at), widths);
let text: String = segment.iter().map(|character| character.0).collect();
if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? {
runs.insert((row_index, at), shaped);
@@ -398,7 +410,7 @@ fn prepare_shaped_rows(
at = end;
}
}
Ok(ShapedRows { runs, advances })
Ok(ShapedRows { runs, advances, char_advances })
}
type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec<u8>)>>;
@@ -1297,6 +1309,70 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
.collect()
}
fn segment_underline_runs(
widths: &[f64],
segment_width: f64,
x: f64,
default_style: usize,
row_styles: Option<&[Option<usize>]>,
pos: usize,
) -> Vec<(usize, f64, f64)> {
let width_sum = widths.iter().copied().sum::<f64>();
let width_scale = if width_sum > 1e-6 { segment_width / width_sum } else { 0.0 };
let mut cursor = x;
let mut runs: Vec<(usize, f64, f64)> = Vec::new();
for (offset, width) in widths.iter().enumerate() {
let style_index = row_styles.and_then(|styles| styles.get(pos.saturating_add(offset))).and_then(|style| *style).unwrap_or(default_style);
let next = cursor + *width * width_scale;
match runs.last_mut() {
Some((prev, _, to)) if *prev == style_index => *to = next,
_ => runs.push((style_index, cursor, next)),
}
cursor = next;
}
runs
}
/// Keep spaces in a subset without a space glyph attached to an adjacent glyph-bearing segment on
/// the same row so `source_show` wraps them in one `/ActualText` span (#4229).
///
/// The returned slot is the space's style index from before that reassignment, where the
/// grouping key changed. Underlines stay with that style; the row itself keeps the neighbour's
/// key so the space is not a group of its own.
fn assign_subset_space_fonts(row: &mut [(char, usize, String)], metrics: &std::collections::BTreeMap<String, Metrics>) -> Vec<Option<usize>> {
let mut right_any = vec![None; row.len()];
let mut next_any: Option<(usize, String)> = None;
for (i, item) in row.iter().enumerate().rev() {
if item.0 != ' ' {
next_any = Some((item.1, item.2.clone()));
} else if let Some(slot) = right_any.get_mut(i) {
*slot = next_any.clone();
}
}
let mut prev_any: Option<(usize, String)> = None;
let mut underline_style = vec![None; row.len()];
for (i, item) in row.iter_mut().enumerate() {
if item.0 != ' ' {
prev_any = Some((item.1, item.2.clone()));
continue;
}
if metrics.get(&item.2).is_none_or(|m| m.encode(" ").is_some()) {
continue;
}
if let Some((style, font)) = prev_any.as_ref().or_else(|| right_any.get(i).and_then(Option::as_ref)) {
let original = item.1;
item.1 = *style;
item.2 = font.clone();
if let Some(slot) = underline_style.get_mut(i)
&& item.1 != original
{
*slot = Some(original);
}
}
}
underline_style
}
/// Operator index of the paragraph's first text object, and of the next line's text object
/// when one follows.
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
@@ -1584,10 +1660,7 @@ pub(crate) fn rewrite_styled_block(
let mut width = 0.0;
let mut at = 0;
while let Some(item) = items.get(at) {
let mut end = at + 1;
while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) {
end += 1;
}
let end = styled_run_end(items, at);
let segment = items.get(at..end).unwrap_or_default();
let string: String = segment.iter().map(|v| v.0).collect();
if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? {
@@ -1612,7 +1685,8 @@ pub(crate) fn rewrite_styled_block(
}
Ok(width)
};
let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let (mut rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?;
let space_styles: Vec<_> = rows.iter_mut().map(|row| assign_subset_space_fonts(row, &metrics)).collect();
let streams = content_streams(doc, &p.dict)?;
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
@@ -1643,7 +1717,7 @@ pub(crate) fn rewrite_styled_block(
validate_contextual_rows(&rows, &kept)?;
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?;
let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped;
let ShapedRows { runs: shaped_runs, advances: segment_advances, char_advances } = shaped;
let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?;
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
@@ -1720,10 +1794,7 @@ pub(crate) fn rewrite_styled_block(
while pos < row.len() {
let Some(item) = row.get(pos) else { break };
let source = lines.get(item.1).unwrap_or(first);
let mut end = pos + 1;
while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) {
end += 1;
}
let end = styled_run_end(row, pos);
let segment = row.get(pos..end).unwrap_or_default();
let string: String = segment.iter().map(|c| c.0).collect();
let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?;
@@ -1779,11 +1850,20 @@ pub(crate) fn rewrite_styled_block(
)?);
block_ops.push(Op::new("ET", vec![]));
}
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, item.1) * source.origin.k;
let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
// A space reassigned onto a neighbour keeps that neighbour's grouping key, and its
// own style for the underline. Stroke each run of one underline style separately.
let widths = char_advances.get(&(row_index, pos)).map(Vec::as_slice).unwrap_or(&[]);
for (style_index, from, to) in
segment_underline_runs(widths, segment_width, x, item.1, space_styles.get(row_index).map(Vec::as_slice), pos)
{
let source = lines.get(style_index).unwrap_or(first);
if underlines.get(&style_index).copied().or(style.underline) != Some(true) || to <= from {
continue;
}
let [r, g, b] = overrides.get(&style_index).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, style_index) * source.origin.k;
let (x0, y0) = back.apply(from - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
let (x1, y1) = back.apply(to - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))]));
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
@@ -1819,3 +1899,19 @@ pub(crate) fn rewrite_styled_block(
crate::draw_on_top(doc, &p, o.ctm, &on_top)?;
Ok(LineEdit { substituted })
}
#[cfg(test)]
mod tests {
use super::styled_run_end;
#[test]
fn styled_runs_stop_at_style_or_font_changes() {
let items = vec![('a', 0, "F1".into()), (' ', 0, "F1".into()), ('b', 1, "F1".into()), ('c', 1, "F2".into())];
assert_eq!(styled_run_end(&items, 0), 2);
assert_eq!(styled_run_end(&items, 1), 2);
assert_eq!(styled_run_end(&items, 2), 3);
assert_eq!(styled_run_end(&items, 3), 4);
assert_eq!(styled_run_end(&items, 4), 4);
assert_eq!(styled_run_end(&[], 0), 0);
}
}
+7 -1
View File
@@ -508,7 +508,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec<usize>, Vec<u32>, Vec<bool>) {
let tight_cluster = (loose_sign(pt) || loose_sign(ct)) && gap < h * 0.6;
let run_start = sc.breaks.binary_search(&c).is_ok();
if let Some(space) = spaces.get_mut(w + 1) {
*space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty();
*space = (gap > threshold || run_start)
&& !cjk
&& !tight_cluster
&& !pt.trim().is_empty()
&& !ct.trim().is_empty()
&& !pt.ends_with(char::is_whitespace)
&& !ct.starts_with(char::is_whitespace);
}
}
// Right-to-left spans are reversed (visual → logical) with their gaps, and only then is a second copy made.