From 2bc2933988a00b0d9e445628c2a46d0d855fa426 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:16:47 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + crates/edit/src/tests.rs | 88 ++++++++++++++++++++---- crates/edit/src/text.rs | 47 ++++++++----- crates/edit/src/text/blocks.rs | 14 ++-- crates/edit/src/text/interp.rs | 38 +++++++++- crates/edit/src/text/lines.rs | 4 ++ crates/edit/src/text/rewrite.rs | 27 ++++++-- crates/edit/src/text_regions/grouping.rs | 49 +++++++++++++ crates/edit/src/text_regions/styled.rs | 22 +++++- crates/ui-egui/src/tools/edit_text_ui.rs | 7 +- crates/ui-egui/tests/it/editing.rs | 4 +- parity/acrobat-features.toml | 4 +- 12 files changed, 255 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 7834b668cb..f7922460c7 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 916872a861..800481d7d0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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::>(); 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); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index eb45209353..7bcff773a8 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -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 { + 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] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..2785f87676 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -25,6 +25,9 @@ pub struct TextBlock { pub rows: Vec>, /// 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)>, /// 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 { +/// 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 diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 16b04b406f..e3e4bf26b3 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -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, + 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; + } } _ => {} } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index c48522984d..78aec5090f 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -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, last: &mut Option, 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, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..9589fad226 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -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) -> Result<(f64, f64, f64), EditError> { + fn write_line( + &self, + index: usize, + line: &str, + soft: bool, + last: bool, + cursor: &mut LineCursor, + ops: &mut Vec, + ) -> 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) -> Result, 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) -> Result, 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, EditError> { +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], 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 68a83569a5..f866daef5e 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -299,6 +299,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { 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 { 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 +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index ccb75b3130..f55d52ddc6 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -405,9 +405,12 @@ fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, -) -> Result<(StyledRows, RowRanges), EditError> { +) -> Result<(StyledRows, RowRanges, 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; 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::>()); 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); diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index c932ed3608..c8bf9867b2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -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). diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 5a124aa390..95ad044b31 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c7069200ab..c9d371cc56 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"