From d9115bb90b5b4375e3dd7ef28c41707c22181809 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 12:46:33 +0530 Subject: [PATCH 1/3] M7: edited text keeps every space typed (#4199, #3797) Rewriting a paragraph in one style split its text at white space and joined the words with one space, so a run of spaces, an indent or a trailing space typed in the editor was saved as one space or none. The text is now kept as typed (other white space becomes a space, and CR or CRLF become line breaks), and the wrapper keeps each run of spaces, dropping only the spaces where a row wraps. A justified (stretched) row still drops its indent and the spaces after its last word, as before, so word spacing reaches only the spaces between its words; keeping an indent on a justified row is a possible follow-up. Centred and right-aligned rows, and the editor's draft box, are measured to the last word, and a paragraph at 0 Tz is not justified. Fixes #4199 Fixes #3797 --- crates/edit/src/tests.rs | 155 +++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 113 ++++++++++++++++++++------- crates/engine/src/tests.rs | 12 +++ 3 files changed, 254 insertions(+), 26 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b1c463a6a..d614f11b6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6571,3 +6571,158 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_)))); assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } + +/// #4199, #3797: every space typed into a paragraph is saved, not folded into one. +#[test] +fn an_edited_paragraph_keeps_every_space_typed() { + for typed in ["Hello world", "Hello world end", " indented and trailing "] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + assert_eq!(text::replace_block(&mut doc, 0, 0, typed).unwrap().substituted, None); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains(&format!("({typed}) Tj")), "{typed:?}: {content}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, typed); + } +} + +/// Spaces after a row's last word are kept but take no part in its alignment: a justified row +/// still reaches the margin, and a centred or right-aligned one sits where it would without them. +#[test] +fn spaces_after_a_rows_last_word_do_not_move_its_alignment() { + let shown = |text: &str, align: added::Align| { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let style = + text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(align), width: Some(300.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(text), &style).unwrap(); + String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned() + }; + for (plain, trailing, align) in [ + ("aaa bbb ccc\nddd eee", "aaa bbb ccc \nddd eee", added::Align::Justify), + ("Title", "Title ", added::Align::Center), + ("Title", "Title ", added::Align::Right), + ] { + let first = trailing.split('\n').next().unwrap(); + let expected = shown(plain, align); + let actual = shown(trailing, align).replace(&format!("({first})"), &format!("({})", first.trim_end())); + assert_eq!(actual, expected, "{trailing:?} {align:?}"); + } +} + +/// A justified row runs from the paragraph's left edge to the margin. Word spacing reaches only +/// the spaces between its words: the spaces before its first word and after its last are left +/// out of a stretched row (as before #4199), so the reopened rows stay inside the paragraph and +/// can be edited again in place. +#[test] +fn a_justified_row_keeps_its_indent_and_ends_at_the_margin() { + let style = text::BlockStyle { + family: Some((added::Family::Helvetica, false, false)), + align: Some(added::Align::Justify), + width: Some(300.0), + ..Default::default() + }; + for typed in [" Pack my box with five dozen jugs\nof liquor", " Pack my box with five dozen jugs \nof liquor"] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap(); + let mut doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let first = lines.iter().find(|l| l.text.contains("Pack")).unwrap(); + assert_eq!(first.text, "Pack my box with five dozen jugs", "{typed:?}"); + assert!((first.rect[0] - 72.0).abs() < 0.5, "{typed:?}: {:?}", first.rect); + assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{typed:?}: {lines:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{typed:?}: {blocks:?}"); + text::replace_block(&mut doc, 0, 0, "Edited again").unwrap(); + let again = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(again.iter().all(|l| l.rect[0] >= 0.0 && l.rect[2] <= 600.0), "{typed:?}: {again:?}"); + } +} + +/// A justified paragraph whose first row was typed with an indent is edited again through the +/// styled path (a draft with a line break): its rows keep their scaling and stay one paragraph +/// inside its width. +#[test] +fn a_justified_paragraph_with_an_indent_edits_again_at_its_own_scale() { + let style = text::BlockStyle { + family: Some((added::Family::Helvetica, false, false)), + align: Some(added::Align::Justify), + width: Some(300.0), + ..Default::default() + }; + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb ccc \nddd"), &style).unwrap(); + let mut doc = reopen(&doc); + let draft = text::text_blocks(&doc, 0).unwrap()[0].editor_text.replacen("ccc", "cc", 1); + assert!(draft.contains('\n'), "{draft:?}"); + text::replace_block(&mut doc, 0, 0, &draft).unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect(); + assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}"); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{blocks:?}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{lines:?}"); +} + +/// A paragraph drawn at `0 Tz` has no width to justify into: nothing is divided by it. The +/// writer writes a number that isn't finite as 0, so the test looks for the word spacing and +/// the move a division by zero would add, rather than for `NaN`. +#[test] +fn justifying_text_at_zero_horizontal_scaling_divides_by_nothing() { + let mut doc = text_page("BT /F1 12 Tf 0 Tz 72 700 Td (Hello world) Tj ET"); + let style = text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(added::Align::Justify), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb\nccc"), &style).unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let block = content.split("ET").next().unwrap(); + assert_eq!(block.matches(" Tw").count(), 1, "only the paragraph's own 0 Tw: {content}"); + let first_row = block.split(" Tm").nth(1).and_then(|rest| rest.split(" Tj").next()).unwrap(); + assert!(!first_row.contains(" Td"), "no move before the first row: {content}"); +} + +/// The preview's box is the box of the rows Apply writes, measured to their last glyphs. +#[test] +fn a_drafts_box_is_the_box_apply_draws() { + let doc = text_page("BT /F1 12 Tf 14 TL 72 700 Td [(First) -2000 (row here )] TJ T* (Second row ) Tj ET"); + let source = text::text_blocks(&doc, 0).unwrap().swap_remove(0); + let draft = source.editor_text.replacen("Second", "Third", 1); + let layout = text::text_edit_layout(&doc, 0, 0, &draft, &text::BlockStyle::default()).unwrap(); + let mut applied = doc.clone(); + text::replace_block(&mut applied, 0, 0, &draft).unwrap(); + let boxed = text::text_lines(&applied, 0) + .unwrap() + .into_iter() + .map(|l| { + let end = l.ink.iter().map(|r| r[2]).fold(f64::NEG_INFINITY, f64::max); + [l.rect[0], l.rect[1], if end.is_finite() { end.min(l.rect[2]) } else { l.rect[2] }, l.rect[3]] + }) + .reduce(|a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]); + let (d, b) = (layout.draft_rect.unwrap(), boxed.unwrap()); + assert!(d.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01), "{draft:?}: preview {d:?}, applied {b:?}"); +} + +/// The draft's box is measured to its last glyph, not over the spaces typed after it. +#[test] +fn a_drafts_box_leaves_out_spaces_after_the_last_word() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let layout = |text: &str| text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap().draft_rect; + assert_eq!(layout("Hello there "), layout("Hello there")); +} + +/// A lone carriage return (an old Mac line ending) breaks the row, as `\n` and `\r\n` do. +#[test] +fn a_lone_carriage_return_breaks_an_edited_paragraph() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::replace_block(&mut doc, 0, 0, "one\rtwo").unwrap(); + let lines: Vec = text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two"]); +} + +/// A subset with no space glyph shows each space as a positioning gap one space wide, so a run +/// of spaces still takes a run's width (#4199). +#[test] +fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET"); + assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None); + let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); + assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 29ac90abc..94443ec63 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -204,7 +204,17 @@ fn layout_draft( *count -= 1; continue; } - rect = Some(rect.map_or(line.rect, |r| [r[0].min(line.rect[0]), r[1].min(line.rect[1]), r[2].max(line.rect[2]), r[3].max(line.rect[3])])); + // Spaces typed after a row's last word are drawn but show nothing, so an upright row is + // measured to the end of its last word: they don't make a draft overflow (#4199). + let mut r = line.rect; + if line.upright() && !line.ink.is_empty() { + if crate::bidi::rtl_base(&line.text) { + r[0] = r[0].max(line.ink.iter().map(|i| i[0]).fold(f64::INFINITY, f64::min)); + } else { + r[2] = r[2].min(line.ink.iter().map(|i| i[2]).fold(f64::NEG_INFINITY, f64::max)); + } + } + rect = Some(rect.map_or(r, |a| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])])); } let overflow = rect .is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5); @@ -2565,20 +2575,28 @@ fn paragraph_separator(c: char) -> bool { /// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); -/// those pieces join without a space. +/// 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. fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { let mut out = Vec::new(); for row in text.split('\n') { let mut line = String::new(); let start = out.len(); - for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) { + // The separators read since the last word: each one boundary between `split` pieces. + let mut gap = 0usize; + for (k, word) in row.split(paragraph_separator).enumerate() { + if k > 0 { + gap = gap.saturating_add(1); + } + if word.is_empty() { + continue; + } + let spaces = " ".repeat(std::mem::take(&mut gap)); for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { - let candidate = match (line.is_empty(), i) { - (true, _) => piece.to_string(), - (false, 0) => format!("{line} {piece}"), - (false, _) => format!("{line}{piece}"), - }; - if !line.is_empty() && advance(&candidate) > width { + 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)); line = piece.to_string(); } else { @@ -2586,6 +2604,7 @@ 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); } @@ -2722,13 +2741,19 @@ fn rewrite_block_from( let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443); within a line, runs of white space become one space. - let text = text + // Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is + // not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white + // space is written as one space. + let text: String = text .unwrap_or(&b.text) - .split('\n') - .map(|row| row.split(paragraph_separator).filter(|word| !word.is_empty()).collect::>().join(" ")) - .collect::>() - .join("\n"); + .replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect(); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; @@ -2926,12 +2951,13 @@ fn rewrite_block_from( block_ops.extend(state.ops()); block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. - let offset = |line: &str| -> f64 { - let free = (width - advance(line)).max(0.0) / k; + // `visible` is the width the line is aligned on (see the loop below). + let offset = |visible: f64| -> f64 { + let free = (width - visible).max(0.0) / k; match align { Some(crate::added::Align::Center) => free / 2.0, // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - advance(line)) / k, + Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k, Some(crate::added::Align::Right) => free, // A justified right-to-left paragraph ends its last line at the right edge. Some(crate::added::Align::Justify) if rtl => free, @@ -2951,11 +2977,29 @@ fn rewrite_block_from( let mut tw_set = 0.0; let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space for (i, line) in wrapped.iter().enumerate() { - let spaces = line.matches(' ').count(); - let tw = - if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 }; - // A stretched line fills the width from the left edge. - let dx = if tw > 0.0 { 0.0 } else { offset(line) }; + // A stretched (justified) line shows only its words, as before #4199: word spacing would + // reach the spaces before its first word and after its last, so they are left out, and + // the line fills the width from the left edge. Nothing to stretch over, or a paragraph + // drawn at `0 Tz`, isn't justified. + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 { + Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite()) + } else { + None + } + .unwrap_or(0.0); + // Other lines show every space typed. A left-to-right one is aligned on its width up to + // its last word; a right-to-left one on its whole width, since the spaces typed after its + // last word are drawn on its left and the ones before its first on its right. + let (shown, visible) = if tw > 0.0 { + (body, advance(body)) + } else if crate::bidi::rtl_base(line) { + (line.as_str(), advance(line)) + } else { + (line.as_str(), advance(line.trim_end_matches(' '))) + }; + let dx = if tw > 0.0 { 0.0 } else { offset(visible) }; if i > 0 || dx != 0.0 { block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } @@ -2967,7 +3011,7 @@ fn rewrite_block_from( tw_set = tw; } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(line); + let visual = crate::bidi::to_visual(shown); // Stretched without word spacing: shown up to and including each space, then moved on. let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; for piece in pieces { @@ -2988,7 +3032,8 @@ fn rewrite_block_from( } } let tw = tw + stretch; - let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * o_state.scale * k) / k; underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); } block_ops.push(Op::new("ET", vec![])); @@ -3354,7 +3399,23 @@ mod tests { 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(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); + } + + /// #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"]); + // An indent alone on a row doesn't wrap before its first word. + assert_eq!(wrap(" abc", 3.0, width), [" 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), [""]); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afe6fd06c..d9d7db68b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6821,6 +6821,18 @@ fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { assert!(Arc::ptr_eq(&redone, &blocks(&session))); } +/// #4199, #3797: the spaces typed into a paragraph are all there after saving and reopening. +#[test] +fn edited_text_keeps_every_space_typed_through_save_and_reopen() { + let (mut s, id) = session_with(1); + let typed = " A b c"; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: typed.into(), style: pdfcraft_edit::BlockStyle::default() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + let blocks = s.get(reopened).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + assert_eq!(blocks[0].editor_text, typed); +} + /// A new document, or one restored from recovery, has never been written where it belongs: undo /// back to the bytes it was opened from must not make it clean, or closing it would discard the /// whole document without asking. Saving makes it clean; Revert of a saved one too. From 3e9988954a17cf9474b841e2ec0511b02bf19f53 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 14:15:17 +0530 Subject: [PATCH 2/3] M7: CR and CRLF break rows on the styled rewrite path too (#4199) A multi-line or multi-style draft is laid out by rewrite_styled_block, which broke rows only at \n: a CRLF left a stray CR before the break and a lone CR broke nothing. The CR of a CRLF is now skipped and a lone CR breaks the row, keeping the draft's character positions, and a draft whose only break is a lone CR takes the same path as one with \n. --- crates/edit/src/tests.rs | 26 ++++++++++++++++++++++++++ crates/edit/src/text.rs | 3 ++- crates/edit/src/text_regions.rs | 16 +++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d614f11b6..918a843da 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6717,6 +6717,32 @@ fn a_lone_carriage_return_breaks_an_edited_paragraph() { assert_eq!(lines, ["one", "two"]); } +/// On the styled rewrite path too, `\r\n` is one row break and a lone `\r` another, and no +/// carriage return reaches the page: with no other formatting, and with a part set in red. +#[test] +fn carriage_returns_break_rows_on_the_styled_path() { + let draft = "one\r\ntwo\rthree"; + let red = text::RangeStyle { + range: 0..3, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + for style in [text::BlockStyle::default(), text::BlockStyle { ranges: vec![red], ..Default::default() }] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + let content = page_content_bytes(&doc, 0); + assert!(!content.contains(&b'\r') && !content.windows(2).any(|w| w == b"\\r"), "{}", String::from_utf8_lossy(&content)); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two", "three"], "{style:?}"); + } +} + /// A subset with no space glyph shows each space as a positioning gap one space wide, so a run /// of spaces still takes a run's width (#4199). #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 94443ec63..24cfaab81 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2726,7 +2726,8 @@ fn rewrite_block_from( && (!style.ranges.is_empty() || style.resource.is_some() || (text.is_some_and(|draft| { - draft.contains('\n') + // A row break, `\n` or a carriage return, takes the path that keeps untouched rows. + draft.contains(['\n', '\r']) || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() }) || registered_face diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a77559494..f64483c4d 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -547,11 +547,20 @@ pub(super) fn rewrite_styled_block( let mut programs: HashMap>>> = HashMap::new(); for (range, index) in styled_spans { let source = lines.get(index).unwrap_or(first); - for ch in draft.get(range).unwrap_or_default().chars() { + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { if ch == '\n' { chars.push((ch, index, source.font.clone())); continue; } + // A carriage return breaks the row too. Before `\n` the pair is one break, so the + // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's + // characters, which rows are counted in. + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } let s = ch.to_string(); let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { @@ -735,6 +744,11 @@ pub(super) fn rewrite_styled_block( let mut at = 0; while at < chars.len() { let Some(item) = chars.get(at) else { break }; + // The CR of a CRLF: the `\n` after it breaks the row. + if item.0 == '\r' { + at += 1; + continue; + } if item.0 == '\n' { row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); From 3225b030f1619efe1faedad6e30ad8dfbc0927c6 Mon Sep 17 00:00:00 2001 From: Jayesh-Prajapati9 Date: Fri, 23 Oct 2026 14:16:13 +0530 Subject: [PATCH 3/3] M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385 The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time mask generation and u128 comparisons, fixed in 0.4.4). It comes in through crypto-bigint for the signing crates. Only its version and checksum change in Cargo.lock. (cherry picked from commit 8a687ee2c6012e3d18399633d2fb9fb3742f02ae) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47d73be44..9129e85d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,9 +1291,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle",