mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
M7: let a list marker open an item and a hanging indent continue it (#2564)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user