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.
This commit is contained in:
ihsan mokhlisse
2026-10-29 10:13:01 +01:00
parent 8a78fb21cf
commit bb7186d87c
8 changed files with 240 additions and 77 deletions
+53
View File
@@ -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]
+18 -3
View File
@@ -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<String> {
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
+15 -6
View File
@@ -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
+21
View File
@@ -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<Shown>,
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;
}
}
_ => {}
+2
View File
@@ -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<TextLine>, last: &mut Option<Pen>, 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,
+20 -9
View File
@@ -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<Op>,
) -> 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<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, 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<Vec<Op>, EditError> {
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result<Vec<Op>, 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)?);
+20 -13
View File
@@ -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<usize> {
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)
}
+91 -46
View File
@@ -405,12 +405,17 @@ fn wrap_styled_rows(
chars: &[StyledChar],
width: f64,
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
) -> Result<(StyledRows, RowRanges, Vec<bool>), EditError> {
) -> Result<(StyledRows, RowRanges, Vec<bool>, Vec<usize>), EditError> {
let mut rows: Vec<Vec<StyledChar>> = 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<Op>, 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<Op>, 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>, usize)>,
at: usize,
mut block_ops: Vec<Op>,
drop: &std::collections::HashSet<usize>,
) -> Vec<Vec<u8>> {
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::<Vec<_>>());
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| {