From bb7186d87c71c05228f879e8ba1cfd5910484583 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:26:05 +0100 Subject: [PATCH] M7: restore the exact gap a wrap consumed, not a guessed space (#2517) wrap() already dropped the whole gap at a wrap point (#4199); rejoining it always guessed a single space back, so two or more typed spaces silently became one, and a Chinese/Japanese word split (which has no space of its own) could gain a spurious one. wrap() now records how many separator characters it consumed at each wrap (0 for a CJK split); the /PCSoft marker carries it as /N (a file saved before this, or by another writer, reads as 1); reflow_text and the mixed-style row editor insert exactly that many spaces back instead of assuming one. --- crates/edit/src/tests.rs | 53 +++++++++ crates/edit/src/text.rs | 21 +++- crates/edit/src/text/blocks.rs | 21 +++- crates/edit/src/text/interp.rs | 21 ++++ crates/edit/src/text/lines.rs | 2 + crates/edit/src/text/rewrite.rs | 29 +++-- crates/edit/src/text_regions/grouping.rs | 33 +++--- crates/edit/src/text_regions/styled.rs | 137 +++++++++++++++-------- 8 files changed, 240 insertions(+), 77 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7ba088722b..5491dfca43 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3283,6 +3283,59 @@ fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour"); } +/// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same +/// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap +/// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back +/// must not quietly turn two typed spaces into one. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space() { + let mut doc = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap().rect; + let wide_width = wide[2] - wide[0]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "the second typed space was lost: {:?}", rewidened.editor_text); +} + +/// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those +/// pieces join without a space"). Narrowing a paragraph until it wraps inside such a word, then +/// widening it back, must restore the two pieces exactly as they were, not insert a space between +/// them. Checked on both the single-style rewrite path and the row-editor (mixed-style) path. +#[test] +fn narrowing_then_widening_a_japanese_word_adds_no_space() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping narrowing_then_widening_a_japanese_word_adds_no_space: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章です"; + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap(); + let wide = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + let wide_width = wide.rect[2] - wide.rect[0]; + + // Narrow enough to force a wrap inside the Japanese word, not just between the two rows. + // A width-only change must pass `reflow_text` explicitly, as the editor UI does: `None` + // falls back to the paragraph's space-joined display text and would not keep a typed break. + text::rewrite_block(&mut doc, 0, 0, Some(&wide.reflow_text), &text::BlockStyle { width: Some(60.0), ..Default::default() }).unwrap(); + let narrow = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert!(narrow.rows.len() > draft.matches('\n').count() + 1, "the narrow box did not wrap: {:?}", narrow.editor_text); + assert_eq!( + narrow.editor_text.replace('\n', ""), + draft.replace('\n', ""), + "wrapping must not add or drop characters: {:?}", + narrow.editor_text + ); + + text::rewrite_block(&mut doc, 0, 0, Some(&narrow.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, draft, "a Japanese word split gained a space it never had: {:?}", rewidened.editor_text); + } +} + /// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a /// short line that had room. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d815ee4282..b366e36f4d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -122,6 +122,10 @@ pub struct TextLine { soft_break: bool, /// This line starts after a break a rewrite kept on purpose (`/PCHard`). hard_break: bool, + /// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a + /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none + /// (#2529). Meaningless otherwise. + soft_gap: usize, } impl TextLine { @@ -210,7 +214,7 @@ mod tests { } fn wrapped(text: &str, width: f64) -> Vec { - wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _)| line).collect() + wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect() } #[test] @@ -226,8 +230,19 @@ mod tests { #[test] fn a_wrap_is_marked_soft_and_a_newline_is_not() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false), ("1".into(), true)]); - assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false), ("Two".into(), false)]); + assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]); + assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]); + } + + /// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not + /// assumed to be one space; a Chinese/Japanese word split, which has no space of its own, + /// is recorded as none. + #[test] + fn a_wrap_records_how_many_spaces_it_consumed() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]); + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]); + assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]); } /// #4199: a run of spaces stays a run, before, between and after words; the spaces where a diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 2785f87676..d6dcc585f1 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -340,13 +340,18 @@ pub(crate) fn paragraph_separator(c: char) -> bool { /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so /// a run of spaces typed between, before or after words stays (#4199); only the spaces where a -/// row wraps are dropped. The bool is true when the line continues the previous one because the -/// next word did not fit, rather than because of a newline. -pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool)> { +/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact +/// count instead of guessing one, #2529). The bool is true when the line continues the previous +/// one because the next word did not fit, rather than because of a newline; the number is how +/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split, +/// which has no space of its own; meaningless when the bool is false). +pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> { let mut out = Vec::new(); for row in text.split('\n') { let mut line = String::new(); let mut soft = false; + // The gap consumed right before `line` started: 0 until a wrap sets it. + let mut wrap_gap = 0usize; let start = out.len(); // The separators read since the last word: each one boundary between `split` pieces. let mut gap = 0usize; @@ -357,13 +362,17 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec if word.is_empty() { continue; } - let spaces = " ".repeat(std::mem::take(&mut gap)); + let gap_here = std::mem::take(&mut gap); + let spaces = " ".repeat(gap_here); for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") }; // A row holding only the spaces that indent it doesn't wrap before its first word. if line.chars().any(|c| c != ' ') && advance(&candidate) > width { - out.push((std::mem::take(&mut line), soft)); + out.push((std::mem::take(&mut line), soft, wrap_gap)); soft = true; + // A mid-word piece (i > 0) had no separator of its own; the first piece of a + // word had exactly the separators just taken. + wrap_gap = if i == 0 { gap_here } else { 0 }; line = piece.to_string(); } else { line = candidate; @@ -372,7 +381,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec } line.push_str(&" ".repeat(gap)); if !line.is_empty() || out.len() == start { - out.push((line, soft)); + out.push((line, soft, wrap_gap)); } } out diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index e3e4bf26b3..73ca1b1424 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -304,6 +304,11 @@ pub(super) struct Shown { /// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line. pub(super) soft_break: bool, pub(super) hard_break: bool, + /// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0 + /// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a + /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), + /// matching the single space a rejoin always used before #2529. + pub(super) soft_gap: usize, } impl Shown { @@ -554,6 +559,7 @@ struct TextRunState<'a> { out: &'a mut Vec, soft_break: bool, hard_break: bool, + soft_gap: usize, } fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { @@ -752,6 +758,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit actual_text: false, soft_break: state.soft_break, hard_break: state.hard_break, + soft_gap: state.soft_gap, }); Ok(()) } @@ -780,6 +787,10 @@ pub(super) fn interpret( // before the next line. Neither name is a paragraph region, so it does not change scope. let mut soft_break = false; let mut hard_break = false; + // `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft` + // with no properties dict (a file saved before this was tracked, or by another writer) reads + // as 1, the single space a rejoin always used before #2529. + let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing @@ -788,6 +799,14 @@ pub(super) fn interpret( if op.name(0) == Some(b"PCSoft") { soft_break = true; hard_break = false; + soft_gap = op + .operands + .get(1) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"N")) + .and_then(Object::as_int) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(1); } else if op.name(0) == Some(b"PCHard") { hard_break = true; soft_break = false; @@ -908,6 +927,7 @@ pub(super) fn interpret( out: &mut out, soft_break, hard_break, + soft_gap, }; show_text(op, i, &mut state)?; // A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break @@ -915,6 +935,7 @@ pub(super) fn interpret( if out.len() > before { soft_break = false; hard_break = false; + soft_gap = 1; } } _ => {} diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 78aec5090f..f811f34ea6 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -282,6 +282,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> runs: vec![(s.text.clone(), s.ink.len(), false)], soft_break: s.soft_break, hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -398,6 +399,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream runs: vec![(s.text.clone(), s.ink.len(), false)], soft_break: s.soft_break, hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 9589fad226..4b6944deeb 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -578,27 +578,38 @@ impl LineWriter<'_> { } } - /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. `wrap`: whether + /// this line continues the previous one because a word did not fit, and, when it does, how + /// many separator characters the wrap consumed (0 for a Chinese/Japanese word split). fn write_line( &self, index: usize, line: &str, - soft: bool, + wrap: (bool, usize), last: bool, cursor: &mut LineCursor, ops: &mut Vec, ) -> Result<(f64, f64, f64), EditError> { let n = pdfcraft_content::num; + let (soft, gap) = wrap; let body = line.trim_matches(' '); let interior = body.matches(' ').count(); let tw = self.stretch(body, last); let (shown, visible) = self.shown(line, body, tw); let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; if index > 0 { - // Inside this text object, so the marker starts and ends here. `/PCSoft` rejoins - // when the box widens. `/PCHard` is a newline that was already in the text. - let name = if soft { "PCSoft" } else { "PCHard" }; - ops.push(Op::new("BMC", vec![Object::name(name)])); + // Inside this text object, so the marker starts and ends here. `/PCHard` is a newline + // that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the + // exact number of separator characters the wrap consumed (0 for a Chinese/Japanese + // word split), so a rejoin restores what was actually there instead of guessing a + // single space and silently dropping the rest of a typed run (#2529). + if soft { + let mut properties = Dict::new(); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } ops.push(Op::new("EMC", vec![])); } if index > 0 || dx != 0.0 { @@ -648,11 +659,11 @@ impl LineWriter<'_> { } /// Writes every wrapped line, returning their underlines. - fn write_all(&self, wrapped: &[(String, bool)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { wrapped .iter() .enumerate() - .map(|(i, (line, soft))| self.write_line(i, line, *soft, i + 1 == wrapped.len(), cursor, ops)) + .map(|(i, (line, soft, gap))| self.write_line(i, line, (*soft, *gap), i + 1 == wrapped.len(), cursor, ops)) .collect() } } @@ -672,7 +683,7 @@ struct BlockPlan<'a> { underline: bool, } -fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], show_font: String) -> Result, EditError> { +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result, EditError> { let n = pdfcraft_content::num; let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index df281eb922..26f9343397 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -365,14 +365,17 @@ fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { let mut out = String::new(); for (index, part) in parts.iter().enumerate() { if index > 0 { - if break_is_soft(block, lines, index) { - if out.ends_with('-') { + match break_gap(block, lines, index) { + Some(_) if out.ends_with('-') => { out.pop(); - } else if !out.ends_with(char::is_whitespace) { - out.push(' '); } - } else { - out.push('\n'); + // `gap` is the exact number of separator characters the wrap consumed (0 for a + // Chinese/Japanese word split, which has no space of its own) when the marker or + // the source recorded it; a line already ending in whitespace is left alone so a + // row whose own text kept a trailing space isn't doubled. + Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)), + Some(_) => {} + None => out.push('\n'), } } out.push_str(part); @@ -380,20 +383,24 @@ fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { out } -fn break_is_soft(block: &TextBlock, lines: &[TextLine], row_index: usize) -> bool { - let Some(line) = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i)) else { return false }; +/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap` +/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this +/// app did not mark (a file written elsewhere, or an older save), a single space when the +/// geometry looks like an ordinary wrapped body line (`#2529`). +fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option { + let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?; if line.hard_break { - return false; + return None; } if line.soft_break { - return true; + return Some(line.soft_gap); } - let Some(prev) = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines)) else { return false }; - let Some(next) = block.rows.get(row_index).and_then(|row| row_rect(row, lines)) else { return false }; + let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?; + let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?; let size = line.size.max(1.0); let slack = block.rect[2] - prev[2]; let width = prev[2] - prev[0]; // Short rows define the right edge by their own letters. A body line that fills the // measure leaves almost no room for the next word. - slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack + (slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1) } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index be7e627ba6..ea18a2a21a 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -405,12 +405,17 @@ fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, -) -> Result<(StyledRows, RowRanges, Vec), EditError> { +) -> Result<(StyledRows, RowRanges, Vec, Vec), EditError> { let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); // True when this row starts because the previous word did not fit, not because of a newline. let mut row_soft = Vec::new(); let mut next_soft = false; + // How many separator characters a wrap trimmed off the row that just ended, for the row + // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // the row it belongs to is soft). Mirrors `next_soft`. + let mut row_gap = Vec::new(); + let mut next_gap = 0usize; let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; @@ -424,6 +429,8 @@ fn wrap_styled_rows( if item.0 == '\n' { row_soft.push(next_soft); next_soft = false; + row_gap.push(next_gap); + next_gap = 0; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -444,11 +451,15 @@ fn wrap_styled_rows( let word = chars.get(at..end).unwrap_or_default(); let word_width = measure(word)?; if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + let mut trimmed = 0usize; while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { row.pop(); + trimmed += 1; } row_soft.push(next_soft); next_soft = true; + row_gap.push(next_gap); + next_gap = trimmed; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -460,10 +471,11 @@ fn wrap_styled_rows( } if !row.is_empty() { row_soft.push(next_soft); + row_gap.push(next_gap); row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - Ok((rows, row_ranges, row_soft)) + Ok((rows, row_ranges, row_soft, row_gap)) } fn apply_range_styles<'a>( @@ -745,6 +757,78 @@ fn rewrite_anchor<'a>( RewriteAnchor { lead, drop, start, split } } +/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when +/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph +/// cannot be mistaken for a neighbour by geometry alone (#2517). +fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) { + if !marked { + return; + } + let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() }; + block_ops.push(Op::new(op, operands)); +} + +/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in +/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of +/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph +/// region, so the lines stay in one scope. +fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { + if row_soft.get(row_index).copied().unwrap_or(false) { + let mut properties = Dict::new(); + let gap = row_gap.get(row_index).copied().unwrap_or(1); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + block_ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + block_ops.push(Op::new("EMC", vec![])); +} + +/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` +/// marker opened at its start and closed at its `next` line or, when nothing follows, after the +/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or +/// beyond `ops.len()`, since `splice`'s callback never visits that index). +fn splice_with_marker( + pieces: &Pieces, + ops: &[Op], + marker: Option<(usize, Option, usize)>, + at: usize, + mut block_ops: Vec, + drop: &std::collections::HashSet, +) -> Vec> { + let data = splice(pieces, ops, |i| { + let mut insert = Vec::new(); + if let Some((start_at, next, close)) = marker { + if i == start_at { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if next.is_none() && i == close { + insert.push(Op::new("EMC", vec![])); + } + if next == Some(i) { + insert.push(Op::new("EMC", vec![])); + } + } + if i == at { + insert.append(&mut block_ops); + } + (insert, !drop.contains(&i)) + }); + if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { + return data; + } + let mut data = data; + if let Some(last) = data.last_mut() { + if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { + last.push(b'\n'); + } + // `splice` never visits `ops.len()`, so a close after the final operator is appended. + last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); + } + data +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -856,7 +940,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let (rows, row_ranges, row_soft) = wrap_styled_rows(&chars, width, measure)?; + let (rows, row_ranges, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -895,9 +979,7 @@ pub(crate) fn rewrite_styled_block( // scope so the neighbour stays out (#2517). A row left in the source stream cannot share // that marker, so it is omitted and geometry groups those rows as before. let marked = kept.iter().all(|row| !*row); - if marked { - block_ops.push(Op::new("BMC", vec![Object::name("P")])); - } + push_whole_paragraph_marker(&mut block_ops, marked, "BMC"); let crop = p.crop(doc); let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); let source_leading = block @@ -926,12 +1008,7 @@ pub(crate) fn rewrite_styled_block( continue; } if !row.is_empty() && row_index > 0 { - // An empty marker between lines. `/PCSoft` is a wrap and rejoins when the box - // widens. `/PCHard` is a newline that was already in the text. Neither is a - // paragraph region, so the lines stay in one scope. - let name = if row_soft.get(row_index).copied().unwrap_or(false) { "PCSoft" } else { "PCHard" }; - block_ops.push(Op::new("BMC", vec![Object::name(name)])); - block_ops.push(Op::new("EMC", vec![])); + push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index); } let w = measured.get(row_index).copied().unwrap_or(0.0); let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); @@ -1076,9 +1153,7 @@ pub(crate) fn rewrite_styled_block( style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) }; } - if marked { - block_ops.push(Op::new("EMC", vec![])); - } + push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); block_ops.push(Op::new("Q", vec![])); block_ops.extend(after.ops()); if split { @@ -1091,37 +1166,7 @@ pub(crate) fn rewrite_styled_block( // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; - let data = splice(&pieces, &ops, |i| { - let mut insert = Vec::new(); - if let Some((start_at, next, close)) = marker { - if i == start_at { - insert.push(Op::new("BMC", vec![Object::name("P")])); - } - if next.is_none() && i == close { - insert.push(Op::new("EMC", vec![])); - } - if next == Some(i) { - insert.push(Op::new("EMC", vec![])); - } - } - if i == at { - insert.append(&mut block_ops); - } - (insert, !drop.contains(&i)) - }); - let data = if marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { - let mut data = data; - if let Some(last) = data.last_mut() { - if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { - last.push(b'\n'); - } - // `splice` never visits `ops.len()`, so a close after the final operator is appended. - last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); - } - data - } else { - data - }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| {