mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 14:08:23 -06:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user