diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..2706961e3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1773,6 +1773,36 @@ fn paragraphs_are_found_and_rewrapped() { assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1)); } +/// A list whose bullet is its own text object at the margin and whose text sits at a hanging +/// indent: the wrapped first item is one paragraph, and editing it keeps the items after it +/// (#2564, the edit that deleted its neighbours). +#[test] +fn a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it() { + let mut doc = text_page( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 700 Tm (First item of the list that wraps) Tj T* (onto a second row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 671.2 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 671.2 Tm (Second item, one row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 656.8 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 656.8 Tm (Third item, one row.) Tj ET", + ); + let items = ["• First item of the list that wraps onto a second row.", "• Second item, one row.", "• Third item, one row."]; + let blocks = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, items, "one paragraph per item, the continuation with its first row"); + // Editing the paragraph the wrapped row belongs to must not take the items after it with it. + let blocks = text::text_blocks(&doc, 0).unwrap(); + let wrapped = blocks.iter().position(|b| b.text.contains("onto a second row.")).unwrap(); + text::replace_block(&mut doc, 0, wrapped, "• First item of the list that wraps onto a second row, edited.").unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + ["• First item of the list that wraps onto a second row, edited.", "• Second item, one row.", "• Third item, one row."], + "the items after the edited one are still on the page" + ); +} + fn page_content_bytes(doc: &Document, page: usize) -> Vec { let p = pdfcraft_model::pages(doc).swap_remove(page); let c = p.dict.get(b"Contents").unwrap(); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519..e4c175859 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -64,6 +64,39 @@ fn same_style(a: &TextLine, b: &TextLine) -> bool { && (a.size - b.size).abs() < 0.01 } +/// Does a row open with a list marker? A bullet or a dash, or an ordered marker — digits (or a +/// single letter) followed by `.` or `)` and then a space or the row's end, so prose such as +/// `1.5` or `Dr. Smith` is left alone. +fn opens_with_marker(text: &str) -> bool { + let Some(first) = text.chars().next() else { return false }; + if matches!(first, '•' | '◦' | '▪' | '‣' | '⁃' | '*' | '-' | '–' | '—') { + return true; + } + if !first.is_ascii_digit() && !first.is_ascii_alphabetic() { + return false; + } + let digits = text.chars().take_while(|c| c.is_ascii_digit()).count(); + let letters = text.chars().take_while(|c| c.is_ascii_alphabetic()).count(); + // Both runs are ASCII characters, so their length in characters is their length in bytes: + // the slice below starts on a character boundary. + let rest = if digits > 0 { + &text[digits..] + } else if letters == 1 { + &text[1..] + } else { + return false; + }; + let mut after = rest.chars(); + matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(|c| c.is_whitespace()) +} + +/// A row that opens with a list marker opens its own item: joining it to the row above is what +/// let an edit of one paragraph delete every item after it (#2564). The other error, splitting +/// an ordinary paragraph, costs one more paragraph to click and never any text — so err this way. +fn starts_list_item(row: &[usize], lines: &[TextLine]) -> bool { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.text.trim_start()).find(|t| !t.is_empty()).is_some_and(opens_with_marker) +} + fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let mut rows: Vec> = Vec::new(); for (i, line) in lines.iter().enumerate() { @@ -98,13 +131,28 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; let joins = blocks.last().is_some_and(|block| { + // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and + // letting it join is what made an edit of the row above delete this item and the ones + // after it. + if starts_list_item(&row, lines) { + return false; + } let Some(prev_row) = block.rows.last() else { return false }; let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; let gap = prev.frame_origin().1 - first.frame_origin().1; let tol = prev.size.max(first.size).max(1.0); - let aligned = - (pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol; + // The block opens with a list marker and this row sits further right: the item's text + // is at a hanging indent, so the row it wrapped onto continues it (#2564). A row at + // the block's own left edge is already covered by the match on `pr[0]`. Both boxes are + // in the frame, as the rest of this comparison is, so turned text matches too. + let hanging = frame[0] > pr[0] + && frame[0] - pr[0] <= tol * 3.0 + && block.lines.first().and_then(|&i| lines.get(i)).is_some_and(|l| opens_with_marker(l.text.trim_start())); + let aligned = (pr[0] - frame[0]).abs() < tol + || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol + || (pr[2] - frame[2]).abs() < tol + || hanging; // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b54..8ac6c6e46 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_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" [[feature]] id = "edit.font-reuse-substitution"