M7: keep a repeated step without TL, and a partial rewrite's neighbour (#2517)

Three or more rows at one repeated step stay one paragraph when the file has no TL, which is how LibreOffice writes each line. A rewrite that leaves a source row still marks the whole paragraph, so the neighbour survives a second edit. Dragging the box width keeps the line breaks.
This commit is contained in:
ihsan mokhlisse
2026-10-29 10:11:18 +01:00
parent 53e6d832af
commit 4ebb575a88
16 changed files with 1013 additions and 13 deletions
+1 -1
View File
@@ -204,7 +204,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is checked against the measured baseline gap: rows at that gap stay one paragraph, and a gap of twice the `TL` does not. Growing the paragraph so the new row is one leading above the next paragraph does not absorb it on a later edit. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
+80
View File
@@ -9499,3 +9499,83 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() {
let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1);
}
fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec<String> {
ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"]
.as_array()
.unwrap()
.iter()
.map(|p| p["text"].as_str().unwrap().to_string())
.collect()
}
/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract.
/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with
/// Td, a two-row file that must stay split, and a partial rewrite that adds a row.
#[test]
fn paragraph_grouping_survives_save_and_reopen_through_tools() {
let dir = workdir("paragraph-grouping");
let mut a = auto(&dir);
for step in [27.6_f64, 28.8] {
let y2 = 700.0 - step;
let y3 = 700.0 - 2.0 * step;
let bodies = [
format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"),
format!(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET"
),
format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"),
];
for (i, body) in bodies.iter().enumerate() {
let path = format!("step-{step}-{i}.pdf");
std::fs::write(dir.join(&path), helvetica_page(body)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
let paras = paragraph_texts(&mut a, doc);
assert_eq!(paras.len(), 1, "{path}: {paras:?}");
assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}");
let extracted = page_text(&mut a, doc);
assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}");
}
}
// One gap and no third row: still two paragraphs.
let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6);
std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap();
let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2);
// Blank line written with Td, not TL. The inner step repeats; the break does not.
let inner = 14.4_f64;
let blank = format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \
BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET",
700.0 - inner,
700.0 - 3.0 * inner,
700.0 - 4.0 * inner
);
std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap();
let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap();
let blank_paras = paragraph_texts(&mut a, blank_doc);
assert_eq!(blank_paras.len(), 2, "{blank_paras:?}");
assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}");
assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}");
// The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen.
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, doc).len(), 2);
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" }));
let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" }));
assert_eq!(incremental["incremental"], true, "{incremental}");
let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]);
ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" }));
let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true }));
assert_eq!(full["incremental"], false, "{full}");
let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]);
let extracted = page_text(&mut a, again);
assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}");
}
+170
View File
@@ -2899,6 +2899,176 @@ fn growing_into_a_blank_line_keeps_the_neighbour_after_save() {
}
}
/// No `TL`: `Td` does not set the leading (`TD` does). Three rows at one repeated step are one
/// paragraph. A single large gap, with no third row at that step, stays a break.
#[test]
fn a_repeated_step_without_tl_is_the_paragraph_leading() {
let step = 27.6_f64;
let bodies = [
format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"),
format!(
"BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Three) Tj ET",
700.0 - step,
700.0 - 2.0 * step
),
format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET",
700.0 - step,
700.0 - 2.0 * step
),
];
for body in bodies {
let doc = text_page(&body);
let lines = text::text_lines(&doc, 0).unwrap();
let gaps: Vec<f64> = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect();
assert!(gaps.iter().all(|gap| (gap - step).abs() < 0.05), "step {step} measured {gaps:?} in {body}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "{body} -> {:?}", blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>());
}
// Two rows and nothing after them: one gap cannot be told from a paragraph break.
let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - step);
assert_eq!(text::text_blocks(&text_page(&pair), 0).unwrap().len(), 2);
// The original blank line, written with Td instead of TL: the inner step repeats, the break does not.
let inner = 14.4_f64;
let blank = format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \
BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET",
700.0 - inner,
700.0 - 3.0 * inner,
700.0 - 4.0 * inner
);
let doc = text_page(&blank);
let lines = text::text_lines(&doc, 0).unwrap();
let break_gap = lines[1].origin_baseline() - lines[2].origin_baseline();
assert!((break_gap - 2.0 * inner).abs() < 0.05, "blank line measured {break_gap}");
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>());
}
/// The first row is unchanged, so the rewrite leaves it in the source stream and skips the
/// paragraph marker. The added row then sits one leading above the neighbour.
#[test]
fn a_partial_rewrite_that_adds_a_row_keeps_the_neighbour() {
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
let mut doc = text_page(src);
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2);
text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap();
let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}");
for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] {
assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."]);
let mut again = saved;
text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap();
assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]);
}
}
/// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie.
/// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one.
#[test]
fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() {
let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha paragraph, first row of text.) Tj T* (Alpha paragraph, second row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 650 Tm (Bravo paragraph, a single row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 600 Tm (Charlie paragraph, first row of text.) Tj T* (Charlie paragraph, second row.) Tj ET";
let mut doc = text_page(src);
let names = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
assert_eq!(names(&doc).len(), 3, "{:?}", names(&doc));
text::replace_block(&mut doc, 0, 0, "Alpha paragraph, first row of text.\nAlpha paragraph, second row.\nAlpha paragraph, a third row.").unwrap();
let after = names(&doc);
assert!(after.iter().any(|t| t.contains("Bravo") && !t.contains("Alpha")), "Bravo was absorbed: {after:?}");
assert!(after.iter().any(|t| t.contains("Charlie")), "Charlie is gone: {after:?}");
let bravo = after.iter().position(|t| t.contains("Bravo")).unwrap();
text::replace_block(&mut doc, 0, bravo, "Bravo paragraph, edited.").unwrap();
let done = names(&doc);
assert!(done.iter().any(|t| t.contains("Charlie")), "editing Bravo removed Charlie: {done:?}");
assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}");
}
/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the
/// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate
/// drawing and delete it.
#[test]
fn resizing_a_grown_paragraph_keeps_the_neighbour() {
let src = "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 doc = text_page(src);
let text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row.";
text::replace_block(&mut doc, 0, 0, text).unwrap();
let lines = text::text_lines(&doc, 0).unwrap();
let added = lines.iter().find(|l| l.text.contains("Alpha next row.")).unwrap();
let neighbour = lines.iter().find(|l| l.text.contains("Neighbor")).unwrap();
assert!((added.origin_baseline() - neighbour.origin_baseline()).abs() < 0.5, "the new row is not on the neighbour");
let width = text::text_blocks(&doc, 0).unwrap()[0].rect[2] - text::text_blocks(&doc, 0).unwrap()[0].rect[0];
for factor in [0.6_f64, 1.6] {
let mut copy = doc.clone();
text::rewrite_block(&mut copy, 0, 0, Some(text), &text::BlockStyle { width: Some((width * factor).max(40.0)), ..Default::default() })
.unwrap();
let texts = text::text_blocks(&copy, 0).unwrap().into_iter().map(|b| b.text).collect::<Vec<_>>();
assert!(texts.iter().any(|t| t.contains("Neighbor stays.")), "width ×{factor} removed the neighbour: {texts:?}");
}
}
/// 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 {
"BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \
BT /F1 12 Tf 72 700 Td (One) Tj ET \
BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \
BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \
BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \
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"
}
fn block_texts(doc: &Document) -> Vec<String> {
text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect()
}
#[test]
fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph() {
let mut doc = text_page(letter_page());
let before = block_texts(&doc);
assert!(before.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "{before:?}");
assert!(before.iter().any(|t| t.contains("One") && t.contains("Three") && !t.contains("Repeated")), "{before:?}");
assert!(before.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")), "{before:?}");
let blocks = text::text_blocks(&doc, 0).unwrap();
let idx = blocks.iter().position(|b| b.text.contains("One") && b.text.contains("Three")).unwrap();
// What the editor committed, including the line breaks. A later width change must keep them.
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();
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);
let after = block_texts(&doc);
let kept = text::text_blocks(&doc, 0).unwrap().into_iter().find(|b| b.editor_text.contains("four")).unwrap();
assert_eq!(kept.editor_text, "One\nTwo\nThree\nfour", "a wider box flattened the line breaks: {:?}", kept.editor_text);
assert!(after.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the heading joined the resized box: {after:?}");
assert!(
after.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")),
"widening example 1 split the next paragraph: {after:?}"
);
assert!(after.iter().any(|t| t.contains("Second paragraph, first row") && t.contains("second row")), "{after:?}");
assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "{after:?}");
}
#[test]
fn a_row_added_one_leading_above_the_neighbour_does_not_absorb_it() {
let mut doc = text_page(letter_page());
let blocks = text::text_blocks(&doc, 0).unwrap();
let idx = blocks.iter().position(|b| b.text.contains("Alpha first row")).unwrap();
text::replace_block(&mut doc, 0, idx, "Alpha first row.\ntest\nAlpha next row.").unwrap();
let doc = reopen(&doc);
let after = block_texts(&doc);
assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "the added row absorbed the neighbour: {after:?}");
}
#[test]
fn paragraphs_are_found_and_rewrapped() {
let mut doc = text_page(
+23 -8
View File
@@ -227,9 +227,10 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
}
}
let mut blocks: Vec<TextBlock> = Vec::new();
for row in rows {
for row_at in 0..rows.len() {
let Some(row) = rows.get(row_at) else { continue };
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 (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
@@ -256,9 +257,10 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
// Once a paragraph has two rows, its own leading is the measured baseline gap. A
// blank line is about twice that, so a wider gap is the next paragraph (#2517). A
// single row uses the text object's TL when it has one: a gap equal to that TL
// joins, and a gap of twice the TL does not. Without a TL, ordinary leading still
// joins and a gap of 2.4 × the size does not. The old cap of 2.5 × the size joined
// the blank line too.
// joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A
// step that repeats on the following row is that leading; one large gap, with no
// second step to compare, stays a break. The old cap of 2.5 × the size joined the
// blank line too.
let own_leading = block.rows.len().checked_sub(2).and_then(|i| {
let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n));
let below = prev_row.first().and_then(|n| lines.get(*n));
@@ -268,7 +270,20 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
}
});
let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4);
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).map_or(tol * 2.05, |lead| lead * 1.5);
// The next row is not in this block yet. Its gap is only a leading when it matches
// this one: a blank line is a single wider gap and has no matching step after it.
let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| {
let nfirst = next.first().and_then(|i| lines.get(*i))?;
let nframe = frame_row_rect(next, lines)?;
let ngap = first.frame_origin().1 - nfirst.frame_origin().1;
let same = same_plane(first, nfirst)
&& (mixed || same_style(first, nfirst))
&& first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4
&& (nframe[0] - frame[0]).abs() < tol;
let scale = gap.abs().max(ngap.abs());
if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None }
});
let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5);
// 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))
@@ -302,7 +317,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
if !block.rows.is_empty() {
block.editor_text.push('\n');
}
for &index in &row {
for &index in row {
let Some(line) = lines.get(index) else { continue };
// Some producers position separate words without encoding a space.
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
@@ -329,7 +344,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
}
block.text.push_str(row_text.trim_matches(paragraph_separator));
block.rect = union(block.rect, rect);
block.rows.push(row);
block.rows.push(row.clone());
}
blocks
}
+36 -1
View File
@@ -609,6 +609,22 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>
.collect()
}
/// Operator index of the paragraph's first text object, and of the next line's text object
/// when one follows. The next index is where a closing marker belongs.
fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option<usize>)> {
let belongs: std::collections::HashSet<usize> = block.lines.iter().copied().collect();
let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?;
let end =
block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?;
let next = lines
.iter()
.enumerate()
.filter(|(index, _)| !belongs.contains(index))
.filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op))
.min();
Some((start, next))
}
struct RewriteAnchor<'a> {
lead: &'a TextLine,
drop: std::collections::HashSet<usize>,
@@ -985,7 +1001,26 @@ pub(crate) fn rewrite_styled_block(
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
let at = if split { start } else { o.bt_op };
let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i)));
// A kept source row sits outside `block_ops`, so a marker only around the rewrite splits
// the paragraph or leaves the added row to join the neighbour. Bound the whole paragraph:
// from its first text object up to, but not including, the next line.
let marker = if marked { None } else { paragraph_span(block, lines) };
if marker.is_some_and(|(_, next)| next.is_none()) {
block_ops.push(Op::new("EMC", vec![]));
}
let data = splice(&pieces, &ops, |i| {
let mut insert = Vec::new();
if marker.is_some_and(|(start_at, _)| i == start_at) {
insert.push(Op::new("BMC", vec![Object::name("P")]));
}
if i == at {
insert.append(&mut block_ops);
}
if marker.is_some_and(|(_, next)| next == Some(i)) {
insert.push(Op::new("EMC", vec![]));
}
(insert, !drop.contains(&i))
});
let contents = rewritten_contents(doc, &streams, data);
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| {
+3 -1
View File
@@ -399,7 +399,9 @@ 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() };
Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style }
// `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 }
} else {
// Translation must keep the selected source operators. Rewriting unchanged text
// would also remove unrelated paragraphs that now overlap this one (#594).
+222
View File
@@ -3668,6 +3668,228 @@ fn a_paragraph_editor_opens_at_full_opacity() {
}
}
/// One US Letter page of Helvetica, for driving the live editor on a known baseline.
fn letter_text(body: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open_letter(name: &'static str, bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1100.0, 1400.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes(name, None, bytes).unwrap();
app
});
h.run_steps(4);
h
}
fn click_page(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) {
let page = h.state().views[0].page_screen_rect(0).expect("the page is on screen");
let at = egui::pos2(page.left() + x / 612.0 * page.width(), page.top() + (792.0 - y) / 792.0 * page.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(4);
}
fn save_live_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
for _ in 0..40 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
}
let dir = std::path::Path::new("/tmp/pdfcraft-visual");
let _ = std::fs::create_dir_all(dir);
if let Ok(image) = h.render() {
let _ = image.save(dir.join(name));
}
}
/// Live editor: a repeated step with no TL opens as one paragraph, and adding a row
/// beside a kept first row leaves the neighbour as its own paragraph on screen.
#[test]
fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() {
let step = 27.6_f32;
let repeated = format!(
"BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET",
700.0 - step,
700.0 - 2.0 * step
);
let mut h = open_letter("repeated.pdf", letter_text(&repeated));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 700.0 - step + 4.0);
let editor = h.state().views[0].line_editor.clone().expect("the middle row opens");
assert!(editor.text.contains("One") && editor.text.contains("Two") && editor.text.contains("Three"), "{}", editor.text);
save_live_shot(&mut h, "live-repeated-step.png");
let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET";
let mut h = open_letter("partial.pdf", letter_text(partial));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 120.0, 704.0);
let editor = h.state().views[0].line_editor.clone().expect("the first paragraph opens");
assert!(editor.text.contains("Alpha first row.") && editor.text.contains("Alpha next row."), "{}", editor.text);
assert!(!editor.text.contains("Neighbor"), "{}", editor.text);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nTwo\nThree".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(6);
let blocks = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert_eq!(blocks, ["Alpha first row. Two Three", "Neighbor stays."]);
save_live_shot(&mut h, "live-after-adding-a-row.png");
click_page(&mut h, 120.0, 660.0);
let neighbour = h.state().views[0].line_editor.clone().expect("the neighbour opens on its own");
assert_eq!(neighbour.text, "Neighbor stays.");
save_live_shot(&mut h, "live-neighbour-open.png");
}
/// Widening example 1 must not pull the heading into that box or split the paragraph under example 2.
#[test]
fn widening_example_one_keeps_the_heading_and_the_next_paragraph() {
let body = "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \
BT /F1 12 Tf 72 700 Td (One) Tj ET \
BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \
BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \
BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \
BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \
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));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 90.0, 690.0);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "One\nTwo\nThree\nfour".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(8);
let (edge, page_id) = {
let s = h.state();
let id = s.views[0].id;
let blocks = s.session.get(id).unwrap().text_blocks(0);
let texts: Vec<_> = blocks.iter().map(|b| b.text.clone()).collect();
assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "commit already joined the heading: {texts:?}");
assert!(
texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")),
"commit already split the next paragraph: {texts:?}"
);
let block = blocks.iter().find(|b| b.text.contains("four")).expect("the edited paragraph");
let page = s.views[0].page_screen_rect(0).unwrap();
let edge = egui::pos2(
page.left() + block.rect[2] as f32 / 612.0 * page.width(),
page.top() + (792.0 - (block.rect[1] + block.rect[3]) as f32 / 2.0) / 792.0 * page.height(),
);
(edge, id)
};
let to = edge + egui::vec2(280.0, 0.0);
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
for k in 1..=6 {
h.hover_at(edge + (to - edge) * (k as f32 / 6.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(8);
let texts: Vec<_> = {
let s = h.state();
s.session.get(page_id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect()
};
let edited = {
let s = h.state();
s.session
.get(page_id)
.unwrap()
.text_blocks(0)
.iter()
.find(|b| b.editor_text.contains("four"))
.expect("the widened paragraph")
.editor_text
.clone()
};
assert_eq!(edited, "One\nTwo\nThree\nfour", "the width handle flattened the line breaks: {edited:?}");
assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the width handle joined the heading: {texts:?}");
assert!(
texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")),
"the width handle split the next paragraph: {texts:?}"
);
save_live_shot(&mut h, "live-example-one-widened.png");
}
/// The live width handle, after a row was added on top of the neighbour.
#[test]
fn resizing_the_live_box_keeps_the_neighbour() {
let partial = "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("partial.pdf", letter_text(partial));
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
click_page(&mut h, 120.0, 414.0);
h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row.".into();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::Enter);
h.run_steps(6);
let before = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert!(before.iter().any(|t| t.contains("Neighbor stays.")), "the edit already dropped it: {before:?}");
let page = h.state().views[0].page_screen_rect(0).unwrap();
let rect = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect;
let edge = egui::pos2(
page.left() + rect[2] as f32 / 612.0 * page.width(),
page.top() + (792.0 - (rect[1] + rect[3]) as f32 / 2.0) / 792.0 * page.height(),
);
let to = edge + egui::vec2(80.0, 0.0);
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(edge + (to - edge) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(6);
let after = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>()
};
assert!(after.iter().any(|t| t.contains("Neighbor stays.")), "resize removed the neighbour: {after:?}");
save_live_shot(&mut h, "live-after-resize.png");
}
#[test]
fn editing_existing_text_in_place() {
let mut h = harness(1, |_| {});
+374
View File
@@ -0,0 +1,374 @@
//! Human sessions for the editor.
//!
//! Each file in `tests/play/*.json` is one session a person can do: open a page, click a
//! paragraph, type, confirm, drag a box edge, then click the paragraph that must still be
//! separate and read it back. Every session is repeated on several desks (window size and
//! zoom), because that is what changes where a click lands.
//!
//! A miss writes a screenshot and the paragraph list to `/tmp/pdfcraft-play/` and fails this
//! test with that report. `cargo test --workspace` runs it on the Linux and Windows CI
//! machines. The paragraph rules themselves live in `pdfcraft-edit` and run on every OS that
//! job tests. A pass covers these sessions and these desks, not every PDF a person might open.
//!
//! Page coordinates are PDF points. The default page is US Letter (612 by 792).
//!
//! ```text
//! cargo test -p pdfcraft-ui-egui --test play -- --nocapture
//! ```
use std::fs;
use std::path::{Path, PathBuf};
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Session {
name: String,
content: String,
/// PDF page size in points. Omitted sessions use US Letter.
#[serde(default = "letter")]
page: [f32; 2],
/// Window sizes and zooms. Omitted sessions use the shared desks.
#[serde(default)]
desks: Vec<Desk>,
steps: Vec<Step>,
}
#[derive(Clone, Debug, Deserialize)]
struct Desk {
width: f32,
height: f32,
/// Zoom percent, as `ui.set zoom` accepts it (`100` is 100%).
zoom: String,
}
fn desks() -> Vec<Desk> {
vec![
Desk { width: 1100.0, height: 1400.0, zoom: "100".into() },
Desk { width: 1280.0, height: 800.0, zoom: "60".into() },
Desk { width: 900.0, height: 700.0, zoom: "50".into() },
]
}
fn letter() -> [f32; 2] {
[612.0, 792.0]
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Step {
Command {
id: String,
},
ClickPage {
x: f32,
y: f32,
},
/// Select everything in the open paragraph and type, including line breaks.
Type {
text: String,
},
/// Replace the open paragraph's text without keystrokes. Prefer `type`.
Editor {
text: String,
},
Key {
key: String,
#[serde(default)]
modifiers: Vec<String>,
},
DragEdge {
contains: String,
dx: f32,
#[serde(default)]
dy: f32,
},
ExpectEditor {
contains: String,
text: String,
},
ExpectParagraph {
text: String,
},
ExpectTogether {
parts: Vec<String>,
},
ExpectApart {
text: String,
not: String,
},
Shot {
name: String,
},
}
#[test]
fn play_sessions_fetch_paragraph_bugs() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/play");
let mut files: Vec<_> = fs::read_dir(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())).map(|e| e.unwrap().path()).collect();
files.retain(|p| p.extension().is_some_and(|x| x == "json"));
files.sort();
assert!(!files.is_empty(), "no sessions in {}", dir.display());
let mut bugs = Vec::new();
for path in &files {
if let Err(report) = play_file(path) {
bugs.push(report);
}
}
assert!(bugs.is_empty(), "\n\n{}", bugs.join("\n\n"));
}
fn play_file(path: &Path) -> Result<(), String> {
let raw = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let session: Session = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?;
let desks = if session.desks.is_empty() { desks() } else { session.desks.clone() };
let mut bugs = Vec::new();
for desk in &desks {
if let Err(report) = play_desk(&session, desk) {
bugs.push(report);
}
}
if bugs.is_empty() { Ok(()) } else { Err(bugs.join("\n")) }
}
fn play_desk(session: &Session, desk: &Desk) -> Result<(), String> {
let mut h = open_letter(&session.name, &session.content, desk);
if let Err(e) = h.state_mut().set_option("zoom", &desk.zoom) {
return Err(format!("{name} at {desk:?}: zoom: {e}", name = session.name));
}
h.run_steps(4);
eprintln!("{name}: {width}×{height} at {zoom}%", name = session.name, width = desk.width, height = desk.height, zoom = desk.zoom);
for (n, step) in session.steps.iter().enumerate() {
if let Err(why) = step.run(&mut h, session) {
let shot = save_shot(&mut h, &format!("{}-{}x{}", session.name, desk.width, desk.height));
let paras = paragraphs(&h);
return Err(format!(
"{name} on a {width}×{height} window at {zoom}% zoom: step {step_no} {step:?}\n{why}\nparagraphs:\n{paras}\nscreenshot: {shot}",
name = session.name,
width = desk.width,
height = desk.height,
zoom = desk.zoom,
step_no = n + 1,
paras = paras.iter().map(|p| format!("- {p}")).collect::<Vec<_>>().join("\n"),
));
}
}
Ok(())
}
impl Step {
fn run(&self, h: &mut Harness<'static, PdfCraftApp>, session: &Session) -> Result<(), String> {
match self {
Step::Command { id } => {
if !h.state_mut().execute(id) {
return Err(format!("command {id:?} did not run"));
}
h.run_steps(2);
}
Step::ClickPage { x, y } => click_page(h, session, *x, *y)?,
Step::Type { text } => type_into_paragraph(h, text)?,
Step::Editor { text } => {
let Some(editor) = h.state_mut().views.first_mut().and_then(|v| v.line_editor.as_mut()) else {
return Err("no paragraph is open for typing".into());
};
editor.text.clone_from(text);
h.run_steps(1);
}
Step::Key { key, modifiers } => {
let Some(key) = Key::from_name(key) else { return Err(format!("unknown key {key:?}")) };
h.key_press_modifiers(modifiers_of(modifiers)?, key);
h.run_steps(8);
}
Step::DragEdge { contains, dx, dy } => drag_edge(h, session, contains, *dx, *dy)?,
Step::ExpectEditor { contains, text } => {
// The open editor is what a click just showed. Otherwise take the shortest
// paragraph that contains the words, so a heading that merely mentions them
// does not win.
let actual = if let Some(open) = h.state().views.first().and_then(|v| v.line_editor.as_ref()) {
open.text.clone()
} else {
let mut matches: Vec<_> =
paragraphs_full(h).into_iter().map(|p| p.editor_text).filter(|p| p.contains(contains.as_str())).collect();
matches.sort_by_key(|p| p.len());
let Some(found) = matches.into_iter().next() else { return Err(format!("no paragraph contains {contains:?}")) };
found
};
if !actual.contains(contains.as_str()) {
return Err(format!("the open paragraph is {actual:?}, which does not contain {contains:?}"));
}
if actual != *text {
return Err(format!("editor text\n actual: {actual:?}\n expect: {text:?}"));
}
}
Step::ExpectParagraph { text } => {
if !paragraphs(h).iter().any(|p| p.contains(text)) {
return Err(format!("no paragraph contains {text:?}"));
}
}
Step::ExpectTogether { parts } => {
if parts.is_empty() {
return Err("expect_together needs at least one part".into());
}
if !paragraphs(h).iter().any(|p| parts.iter().all(|part| p.contains(part))) {
return Err(format!("these are not in the same paragraph: {parts:?}"));
}
}
Step::ExpectApart { text, not } => {
let paras = paragraphs(h);
if !paras.iter().any(|p| p.contains(text)) {
return Err(format!("no paragraph contains {text:?}"));
}
// A heading can mention both strings. The paragraph itself is one that has
// `text` and does not also have `not`.
if !paras.iter().any(|p| p.contains(text) && !p.contains(not)) {
return Err(format!("{text:?} was absorbed into a paragraph that also has {not:?}"));
}
}
Step::Shot { name } => {
let _ = save_named(&mut *h, name);
}
}
Ok(())
}
}
fn type_into_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str) -> Result<(), String> {
if h.state().views.first().and_then(|v| v.line_editor.as_ref()).is_none() {
return Err("no paragraph is open for typing".into());
}
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.run_steps(1);
// One text event keeps the line breaks. The multiline editor inserts them.
h.event(egui::Event::Text(text.into()));
h.run_steps(2);
Ok(())
}
fn drag_edge(h: &mut Harness<'static, PdfCraftApp>, session: &Session, contains: &str, dx: f32, dy: f32) -> Result<(), String> {
let edge = {
let s = h.state();
let Some(view) = s.views.first() else { return Err("no document is open".into()) };
let Some(page) = view.page_screen_rect(0) else { return Err("the page is not on screen".into()) };
let Some(doc) = s.session.get(view.id) else { return Err("the document is gone".into()) };
let blocks = doc.text_blocks(0);
let Some(block) = blocks.iter().find(|b| b.editor_text.contains(contains) || b.text.contains(contains)) else {
return Err(format!("no paragraph contains {contains:?}"));
};
let rect = block.rect;
let [pw, ph] = session.page;
egui::pos2(page.left() + rect[2] as f32 / pw * page.width(), page.top() + (ph - (rect[1] + rect[3]) as f32 / 2.0) / ph * page.height())
};
let to = edge + egui::vec2(dx, dy);
h.hover_at(edge);
h.run_steps(1);
h.drag_at(edge);
h.run_steps(1);
for k in 1..=6 {
h.hover_at(edge + (to - edge) * (k as f32 / 6.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(8);
Ok(())
}
fn click_page(h: &mut Harness<'static, PdfCraftApp>, session: &Session, x: f32, y: f32) -> Result<(), String> {
let page = h.state().views.first().and_then(|v| v.page_screen_rect(0)).ok_or("the page is not on screen")?;
let [pw, ph] = session.page;
let at = egui::pos2(page.left() + x / pw * page.width(), page.top() + (ph - y) / ph * page.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(4);
Ok(())
}
fn modifiers_of(names: &[String]) -> Result<Modifiers, String> {
let mut m = Modifiers::NONE;
for name in names {
match name.as_str() {
"command" | "cmd" => m.command = true,
"shift" => m.shift = true,
"alt" | "option" => m.alt = true,
"ctrl" | "control" => m.ctrl = true,
other => return Err(format!("unknown modifier {other:?}")),
}
}
if m.command {
m.mac_cmd = cfg!(target_os = "macos");
m.ctrl |= !cfg!(target_os = "macos");
}
Ok(m)
}
fn paragraphs(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
paragraphs_full(h).into_iter().map(|b| b.text).collect()
}
fn paragraphs_full(h: &Harness<'static, PdfCraftApp>) -> Vec<pdfcraft_engine::TextBlock> {
let s = h.state();
let Some(view) = s.views.first() else { return Vec::new() };
s.session.get(view.id).map(|d| d.text_blocks(0)).unwrap_or_default()
}
fn save_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
save_named(h, &safe)
}
fn save_named(h: &mut Harness<'static, PdfCraftApp>, name: &str) -> String {
for _ in 0..20 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
}
let dir = PathBuf::from("/tmp/pdfcraft-play");
let _ = fs::create_dir_all(&dir);
let path = dir.join(format!("{name}.png"));
if let Ok(image) = h.render() {
let _ = image.save(&path);
}
path.display().to_string()
}
fn open_letter(name: &str, content: &str, desk: &Desk) -> Harness<'static, PdfCraftApp> {
let bytes = letter_pdf(content);
let title = name.to_string();
let mut h = Harness::builder().with_size(egui::vec2(desk.width, desk.height)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
let _ = app.set_option("language", "en");
let _ = app.open_bytes(&title, None, bytes);
app
});
h.run_steps(4);
h
}
fn letter_pdf(content: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
@@ -0,0 +1,17 @@
{
"name": "blank line edit",
"content": "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET 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",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 140, "y": 554}},
{"expect_editor": {"contains": "First paragraph, first row", "text": "First paragraph, first row.\nFirst paragraph, second row."}},
{"type": {"text": "First paragraph, first row.\nChanged."}},
{"key": {"key": "Enter", "modifiers": ["command"]}},
{"expect_together": {"parts": ["First paragraph, first row", "Changed"]}},
{"expect_together": {"parts": ["Second paragraph, first row", "Second paragraph, second row"]}},
{"expect_apart": {"text": "Second paragraph, first row", "not": "Changed"}},
{"click_page": {"x": 140, "y": 510}},
{"expect_editor": {"contains": "Second paragraph, first row", "text": "Second paragraph, first row.\nSecond paragraph, second row."}},
{"shot": {"name": "blank-line-edit"}}
]
}
@@ -0,0 +1,18 @@
{
"name": "partial rewrite keeps neighbor",
"content": "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 120, "y": 704}},
{"type": {"text": "Alpha first row.\nTwo\nThree"}},
{"key": {"key": "Enter", "modifiers": ["command"]}},
{"click_page": {"x": 120, "y": 660}},
{"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}},
{"click_page": {"x": 120, "y": 704}},
{"type": {"text": "Alpha first row.\nTwo\nFour"}},
{"key": {"key": "Enter", "modifiers": ["command"]}},
{"click_page": {"x": 120, "y": 660}},
{"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}},
{"shot": {"name": "partial-rewrite-keeps-neighbor"}}
]
}
@@ -0,0 +1,11 @@
{
"name": "repeated step opens as one",
"content": "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 500 Tm (Kept.) Tj ET",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 90, "y": 676}},
{"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}},
{"click_page": {"x": 90, "y": 504}},
{"expect_editor": {"contains": "Kept", "text": "Kept."}}
]
}
@@ -0,0 +1,9 @@
{
"name": "repeated step Td inside one BT",
"content": "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -27.6 Td (Two) Tj 0 -27.6 Td (Three) Tj ET",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 90, "y": 676}},
{"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}}
]
}
@@ -0,0 +1,9 @@
{
"name": "repeated step one Tm per row",
"content": "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 672.4 Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 644.8 Tm (Three) Tj ET",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 90, "y": 676}},
{"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}}
]
}
@@ -0,0 +1,19 @@
{
"name": "resize grown neighbor",
"content": "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",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 120, "y": 414}},
{"type": {"text": "Alpha first row.\nthis is another\nalpha test\nAlpha next row."}},
{"key": {"key": "Enter", "modifiers": ["command"]}},
{"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}},
{"drag_edge": {"contains": "Alpha next row", "dx": 160}},
{"expect_together": {"parts": ["Alpha first row.", "this is another", "alpha test", "Alpha next row."]}},
{"drag_edge": {"contains": "Alpha next row", "dx": -80}},
{"expect_together": {"parts": ["Alpha first row.", "this is another", "alpha test", "Alpha next row."]}},
{"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}},
{"click_page": {"x": 110, "y": 370}},
{"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}},
{"shot": {"name": "resize-grown-neighbor"}}
]
}
@@ -0,0 +1,19 @@
{
"name": "widen example 1",
"content": "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET 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",
"steps": [
{"command": {"id": "edit.edit_text"}},
{"click_page": {"x": 90, "y": 690}},
{"type": {"text": "One\nTwo\nThree\nfour"}},
{"key": {"key": "Enter", "modifiers": ["command"]}},
{"drag_edge": {"contains": "four", "dx": 280}},
{"expect_editor": {"contains": "four", "text": "One\nTwo\nThree\nfour"}},
{"expect_paragraph": {"text": "1. Repeated step, no TL. Click Two."}},
{"expect_together": {"parts": ["First paragraph, first row", "First paragraph, second row"]}},
{"expect_together": {"parts": ["Second paragraph, first row", "Second paragraph, second row"]}},
{"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}},
{"click_page": {"x": 110, "y": 370}},
{"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}},
{"shot": {"name": "widen-example-one"}}
]
}
+2 -2
View File
@@ -1914,8 +1914,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::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading: the measured baseline gap equals it, a gap of that size stays one paragraph even above twice the font size, and a gap of twice the TL is the next paragraph. A rewrite of every row is marked, so a new row one leading above the next paragraph does not absorb it on a later edit. 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. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478)"
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"]
notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. 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. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL."
[[feature]]
id = "edit.font-reuse-substitution"