M7: rejoin a wrapped line when the box is widened (#2517)

A newline that was typed stays. A wrap is marked so a later width change can reflow it, and a short unmarked line stays broken.
This commit is contained in:
ihsan mokhlisse
2026-10-29 10:11:18 +01:00
parent a84ae042d1
commit 2bc2933988
12 changed files with 255 additions and 50 deletions
+1
View File
@@ -9,3 +9,4 @@ plan/
/assets/models
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
/craft-fonts
.claude/
+74 -14
View File
@@ -2807,9 +2807,8 @@ fn a_blank_line_keeps_the_next_paragraph() {
// The second row is left in the source stream. A marker around only the rewritten row
// would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph.
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
if let Some(start) = content.find("/P BMC") {
let end = start + content[start..].find("EMC").unwrap_or(content.len() - start);
let marked = &content[start..end];
if content.contains("/P BMC") {
let marked = region_through_matching_close(&content, "/P BMC");
assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}");
assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}");
}
@@ -2885,8 +2884,7 @@ fn growing_into_a_blank_line_keeps_the_neighbour_after_save() {
let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let marked = content.find("/P BMC").and_then(|start| content[start..].find("EMC").map(|end| content[start..start + end].to_owned()));
let marked = marked.unwrap_or_else(|| panic!("a full rewrite did not mark its rows: {content}"));
let marked = region_through_matching_close(&content, "/P BMC");
for row in ["(One)", "(Two)", "(Three)"] {
assert!(marked.contains(row), "{row} is outside the marker: {content}");
}
@@ -3109,20 +3107,42 @@ fn page_content(doc: &Document) -> String {
/// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object.
fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) {
let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}"));
let end = content[start..].find("EMC").map(|at| start + at).unwrap_or_else(|| panic!("missing EMC after {open}: {content}"));
let marked = &content[start..end];
let marked = region_through_matching_close(content, open);
assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}");
let mut text_objects = 0i32;
assert!(!marker_crosses_a_text_object(content), "a marker opened outside a text object and closed inside it: {content}");
}
/// A marker may sit inside one text object. It may not open outside one and close inside it.
fn marker_crosses_a_text_object(content: &str) -> bool {
let mut text = 0i32;
let mut opened_outside = 0i32;
let mut opened_inside = 0i32;
for token in content.split_whitespace() {
match token {
"BT" => text_objects += 1,
"ET" => text_objects -= 1,
"EMC" => assert_eq!(text_objects, 0, "EMC inside a text object: {content}"),
"BT" => text += 1,
"ET" => text -= 1,
"BMC" | "BDC" => {
if text == 0 {
opened_outside += 1;
} else {
opened_inside += 1;
}
}
"EMC" => {
if text > 0 {
if opened_inside > 0 {
opened_inside -= 1;
} else {
return true;
}
} else if opened_outside > 0 {
opened_outside -= 1;
}
}
_ => {}
}
}
assert_eq!(text_objects, 0, "a text object was left open: {content}");
text != 0 || opened_outside != 0 || opened_inside != 0
}
/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the
@@ -3149,6 +3169,45 @@ fn resizing_a_grown_paragraph_keeps_the_neighbour() {
}
}
/// Narrowing wraps a line. Widening it again rejoins that wrap. A newline that was typed stays.
#[test]
fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() {
let mut doc = text_page("BT /F1 24 Tf 20 150 Td (Page 1) Tj ET");
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!(block.editor_text.contains('\n'), "the narrow box did not wrap: {}", block.editor_text);
assert_eq!(block.reflow_text, "Page 1", "a wrap was kept as a hard break: {}", block.reflow_text);
text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap();
let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["Page 1"]);
let mut typed = text_page("BT /F1 12 Tf 72 700 Td (One) Tj ET");
text::replace_block(&mut typed, 0, 0, "One\nTwo\nThree\nfour").unwrap();
let block = text::text_blocks(&typed, 0).unwrap().into_iter().next().unwrap();
assert_eq!(block.reflow_text, "One\nTwo\nThree\nfour", "a typed break was rejoined: {}", block.reflow_text);
let width = (block.rect[2] - block.rect[0]) * 6.0;
text::rewrite_block(&mut typed, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(width.max(180.0)), ..Default::default() })
.unwrap();
assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour");
}
/// 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]
fn an_unmarked_full_line_rejoins_and_a_short_break_stays() {
let long = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (This sentence is long enough to fill a measure.) Tj ET \
BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Tail) Tj ET";
let doc = text_page(long);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap();
assert_eq!(block.rows.len(), 2, "{}", block.editor_text);
assert_eq!(block.reflow_text, "This sentence is long enough to fill a measure. Tail", "{}", block.reflow_text);
let short = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Two) Tj ET";
let doc = text_page(short);
let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap();
assert_eq!(block.reflow_text, "One\nTwo", "{}", block.reflow_text);
}
/// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the
/// heading above it and both paragraphs under example 2 as they were.
fn letter_page() -> &'static str {
@@ -3181,7 +3240,8 @@ fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph
text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap();
let shown = blocks[idx].editor_text.clone();
assert_eq!(blocks[idx].reflow_text, "One\nTwo\nThree\nfour", "a typed break was treated as a wrap: {}", blocks[idx].reflow_text);
let shown = blocks[idx].reflow_text.clone();
let width = blocks[idx].rect[2] - blocks[idx].rect[0];
text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap();
let doc = reopen(&doc);
+30 -17
View File
@@ -118,6 +118,10 @@ pub struct TextLine {
/// `ink`, and whether joining put a space before it. Empty when the line's text was put
/// into another order (see [`TextLine::runs`]).
runs: Vec<(String, usize, bool)>,
/// This line continues the previous one because a rewrite wrapped it (`/PCSoft`).
soft_break: bool,
/// This line starts after a break a rewrite kept on purpose (`/PCHard`).
hard_break: bool,
}
impl TextLine {
@@ -154,6 +158,7 @@ mod tests {
lines: Vec::new(),
rows: Vec::new(),
editor_text: text.into(),
reflow_text: text.into(),
spans: Vec::new(),
invisible: false,
}
@@ -204,40 +209,48 @@ mod tests {
assert!(!same_base_font("Arial", ""));
}
fn wrapped(text: &str, width: f64) -> Vec<String> {
wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _)| line).collect()
}
#[test]
fn paragraph_wrapping_keeps_ideographic_spaces() {
assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]);
assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]);
assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]);
}
#[test]
fn a_wrap_is_marked_soft_and_a_newline_is_not() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]);
assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
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)]);
}
/// #4199: a run of spaces stays a run, before, between and after words; the spaces where a
/// row wraps go, and each other white-space character is one space.
#[test]
fn paragraph_wrapping_keeps_every_space_typed() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("a b c", 20.0, width), ["a b c"]);
assert_eq!(wrap(" a b ", 20.0, width), [" a b "]);
assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]);
assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]);
assert_eq!(wrapped("a b c", 20.0), ["a b c"]);
assert_eq!(wrapped(" a b ", 20.0), [" a b "]);
assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]);
assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]);
// An indent alone on a row doesn't wrap before its first word.
assert_eq!(wrap(" abc", 3.0, width), [" abc"]);
assert_eq!(wrapped(" abc", 3.0), [" abc"]);
// The rewrite turns `\r\n` and `\r` into `\n` before wrapping.
assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]);
assert_eq!(wrap(" ", 3.0, width), [" "]);
assert_eq!(wrap("", 3.0, width), [""]);
assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]);
assert_eq!(wrapped(" ", 3.0), [" "]);
assert_eq!(wrapped("", 3.0), [""]);
}
#[test]
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
let width = |s: &str| s.chars().count() as f64;
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]);
// The comma can't begin a row, so the character before it moves down with it.
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]);
// Pieces of one word join without a space; separate words keep theirs.
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]);
assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]);
}
#[test]
+10 -4
View File
@@ -25,6 +25,9 @@ pub struct TextBlock {
pub rows: Vec<Vec<usize>>,
/// Text for the inline editor, with a newline only between visual rows.
pub editor_text: String,
/// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a
/// short line that still had room for the next word, stays a newline.
pub reflow_text: String,
/// Byte ranges in `editor_text` and their source line indexes.
pub spans: Vec<(std::ops::Range<usize>, usize)>,
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
@@ -337,11 +340,13 @@ 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.
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
/// 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)> {
let mut out = Vec::new();
for row in text.split('\n') {
let mut line = String::new();
let mut soft = false;
let start = out.len();
// The separators read since the last word: each one boundary between `split` pieces.
let mut gap = 0usize;
@@ -357,7 +362,8 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec
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));
out.push((std::mem::take(&mut line), soft));
soft = true;
line = piece.to_string();
} else {
line = candidate;
@@ -366,7 +372,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);
out.push((line, soft));
}
}
out
+37 -1
View File
@@ -301,6 +301,9 @@ pub(super) struct Shown {
pub(super) paint: Paint,
pub(super) logical_order: bool,
pub(super) actual_text: bool,
/// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line.
pub(super) soft_break: bool,
pub(super) hard_break: bool,
}
impl Shown {
@@ -549,6 +552,8 @@ struct TextRunState<'a> {
marked: &'a MarkedContent,
carry: &'a mut Carry,
out: &'a mut Vec<Shown>,
soft_break: bool,
hard_break: bool,
}
fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> {
@@ -745,6 +750,8 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit
paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() },
logical_order: false,
actual_text: false,
soft_break: state.soft_break,
hard_break: state.hard_break,
});
Ok(())
}
@@ -769,11 +776,22 @@ pub(super) fn interpret(
let mut bt = 0usize;
let mut marked =
MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() };
// A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker
// 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;
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
// sequence (#3573). Both stacks stay bounded by the number of operators.
b"BMC" | b"BDC" => {
if op.name(0) == Some(b"PCSoft") {
soft_break = true;
hard_break = false;
} else if op.name(0) == Some(b"PCHard") {
hard_break = true;
soft_break = false;
}
marked.begin(doc, op, i, logical_text, out.len());
}
b"EMC" => {
@@ -878,8 +896,26 @@ pub(super) fn interpret(
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out };
let before = out.len();
let mut state = TextRunState {
ts: &mut ts,
tm: &mut tm,
tlm: &mut tlm,
at_bt: &at_bt,
bt,
marked: &marked,
carry,
out: &mut out,
soft_break,
hard_break,
};
show_text(op, i, &mut state)?;
// A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break
// belongs to whichever text-showing operator comes next.
if out.len() > before {
soft_break = false;
hard_break = false;
}
}
_ => {}
}
+4
View File
@@ -280,6 +280,8 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
clips: s.state.render_mode >= 4,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
runs: vec![(s.text.clone(), s.ink.len(), false)],
soft_break: s.soft_break,
hard_break: s.hard_break,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -394,6 +396,8 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
clips: s.state.render_mode >= 4,
unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned),
runs: vec![(s.text.clone(), s.ink.len(), false)],
soft_break: s.soft_break,
hard_break: s.hard_break,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
+23 -4
View File
@@ -579,13 +579,28 @@ impl LineWriter<'_> {
}
/// Writes one wrapped line, returning its underline (x0, x1, y) in text space.
fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<(f64, f64, f64), EditError> {
fn write_line(
&self,
index: usize,
line: &str,
soft: bool,
last: bool,
cursor: &mut LineCursor,
ops: &mut Vec<Op>,
) -> Result<(f64, f64, f64), EditError> {
let n = pdfcraft_content::num;
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)]));
ops.push(Op::new("EMC", vec![]));
}
if index > 0 || dx != 0.0 {
ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })]));
}
@@ -633,8 +648,12 @@ impl LineWriter<'_> {
}
/// Writes every wrapped line, returning their underlines.
fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec<Op>) -> Result<Vec<(f64, f64, f64)>, EditError> {
wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect()
fn write_all(&self, wrapped: &[(String, bool)], 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))
.collect()
}
}
@@ -653,7 +672,7 @@ struct BlockPlan<'a> {
underline: bool,
}
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result<Vec<Op>, EditError> {
fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], 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)?);
+49
View File
@@ -299,6 +299,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
blocks.push(TextBlock {
text: String::new(),
editor_text: String::new(),
reflow_text: String::new(),
spans: Vec::new(),
rect,
base_font: first.base_font.clone(),
@@ -346,5 +347,53 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
block.rect = union(block.rect, rect);
block.rows.push(row.clone());
}
for block in &mut blocks {
block.reflow_text = reflow_text(block, lines);
}
blocks
}
/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard`
/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches
/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a
/// file written elsewhere wraps a body line.
fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String {
let parts: Vec<&str> = block.editor_text.split('\n').collect();
if parts.len() != block.rows.len() {
return block.editor_text.clone();
}
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('-') {
out.pop();
} else if !out.ends_with(char::is_whitespace) {
out.push(' ');
}
} else {
out.push('\n');
}
}
out.push_str(part);
}
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 };
if line.hard_break {
return false;
}
if line.soft_break {
return true;
}
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 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
}
+19 -3
View File
@@ -405,9 +405,12 @@ fn wrap_styled_rows(
chars: &[StyledChar],
width: f64,
measure: impl Fn(&[StyledChar]) -> Result<f64, EditError>,
) -> Result<(StyledRows, RowRanges), EditError> {
) -> Result<(StyledRows, RowRanges, Vec<bool>), 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;
let mut row_start = 0;
let mut row = Vec::new();
let mut row_width = 0.0;
@@ -419,6 +422,8 @@ fn wrap_styled_rows(
continue;
}
if item.0 == '\n' {
row_soft.push(next_soft);
next_soft = false;
row_ranges.push(row_start..row_start + row.len());
rows.push(std::mem::take(&mut row));
row_width = 0.0;
@@ -442,6 +447,8 @@ fn wrap_styled_rows(
while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) {
row.pop();
}
row_soft.push(next_soft);
next_soft = true;
row_ranges.push(row_start..row_start + row.len());
rows.push(std::mem::take(&mut row));
row_width = 0.0;
@@ -452,10 +459,11 @@ fn wrap_styled_rows(
at = end;
}
if !row.is_empty() {
row_soft.push(next_soft);
row_ranges.push(row_start..row_start + row.len());
rows.push(row);
}
Ok((rows, row_ranges))
Ok((rows, row_ranges, row_soft))
}
fn apply_range_styles<'a>(
@@ -822,7 +830,7 @@ pub(crate) fn rewrite_styled_block(
}
Ok(width)
};
let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?;
let (rows, row_ranges, row_soft) = 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;
@@ -891,6 +899,14 @@ pub(crate) fn rewrite_styled_block(
baseline -= source_leading;
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![]));
}
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 });
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
+4 -3
View File
@@ -399,9 +399,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]);
let shift = to[0] - from[0];
let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() };
// `text` joins rows with spaces. The editor text is what the paragraph actually
// is, line breaks included; a width change must not throw those breaks away.
Edit::EditTextBlock { page, block: d.block, text: l.editor_text.clone(), style }
// `editor_text` has a newline for every visual row, so a wrap would come back as a
// hard break and a wider box would not reflow it. `reflow_text` rejoins a wrap
// and keeps a newline that was typed.
Edit::EditTextBlock { page, block: d.block, text: l.reflow_text.clone(), style }
} else {
// Translation must keep the selected source operators. Rewriting unchanged text
// would also remove unrelated paragraphs that now overlap this one (#594).
+2 -2
View File
@@ -3738,7 +3738,7 @@ fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() {
700.0 - step,
700.0 - 2.0 * step
);
let mut h = open_letter("repeated.pdf", letter_text(&repeated));
let mut h = open_letter("repeated.pdf", letter_text(repeated.as_str()));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 700.0 - step + 4.0);
@@ -3785,7 +3785,7 @@ fn widening_example_one_keeps_the_heading_and_the_next_paragraph() {
BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET";
let mut h = open_letter("letter.pdf", letter_text(&body));
let mut h = open_letter("letter.pdf", letter_text(body));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 690.0);
+2 -2
View File
@@ -1902,8 +1902,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)"
[[feature]]
id = "edit.paragraph-detection"