M7: let a list marker open an item and a hanging indent continue it (#2564)

This commit is contained in:
Krzysztof Cieślik
2026-10-16 01:30:11 +02:00
parent dc41044ba7
commit a3cbb85536
3 changed files with 82 additions and 4 deletions
+30
View File
@@ -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<u8> {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let c = p.dict.get(b"Contents").unwrap();
+50 -2
View File
@@ -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<TextBlock> {
let mut rows: Vec<Vec<usize>> = Vec::new();
for (i, line) in lines.iter().enumerate() {
@@ -98,13 +131,28 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
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))
+2 -2
View File
@@ -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"