diff --git a/ROADMAP.md b/ROADMAP.md index e8f8c5a69d..858ab719c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -201,6 +202,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **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. diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index b136076bc8..b56b04d92f 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -538,7 +538,17 @@ impl Automation { }; let index = self.paragraph_index(id, page, k, &blocks)?; let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let width = a.opt_num("width")?; + // A width change with no new text is the box-edge drag. `reflow_text` rejoins a + // wrap with the spaces it consumed and keeps a newline that was typed. `text` + // joins every row with one space, so that drag would turn two typed spaces into + // one (#2529). A move or a formatting change still uses `text`: those edits are + // what the stale-paragraph checks regroup with, and they are not a reflow. + let text = match a.opt_str("text")? { + Some(text) => text.to_owned(), + None if width.is_some() => block.reflow_text.clone(), + None => block.text, + }; let mut style = pdfcraft_engine::BlockStyle { ranges: parse_style_ranges(a.get("ranges"))?, resource: a.opt_str("font_resource")?.map(str::to_owned), @@ -548,7 +558,7 @@ impl Automation { line_spacing: a.opt_num("line_spacing")?, char_spacing: a.opt_num("char_spacing")?, scale: a.opt_num("scale")?, - width: a.opt_num("width")?, + width, ..Default::default() }; let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index cdef5dc5af..3772f2fb70 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9496,6 +9496,33 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() { assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); } +/// #2529, through the same tools as a width drag. The typed double space falls on the wrap. +/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite +/// both still have both spaces after the box is widened again. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() { + let dir = workdir("double-space-width"); + std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 })); + let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]; + assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}"); + for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] { + ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full })); + let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 })); + let wide_name = format!("wide-{name}"); + ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full })); + let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}"); + } +} + fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec { ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"] .as_array() diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5491dfca43..b601db5654 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3286,18 +3286,32 @@ fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { /// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same /// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap /// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back -/// must not quietly turn two typed spaces into one. +/// must not quietly turn two typed spaces into one. The count is stored on the wrap marker, so +/// it has to survive an incremental save and a full rewrite, not only the in-memory rewrite. #[test] fn narrowing_then_widening_keeps_a_typed_double_space() { - let mut doc = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); - let wide = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap().rect; - let wide_width = wide[2] - wide[0]; - text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); - let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); - assert_eq!(block.editor_text, "One\nTwo", "the narrow box did not wrap between the two typed spaces: {}", block.editor_text); - text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); - let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); - assert_eq!(rewidened.editor_text, "One Two", "the second typed space was lost: {:?}", rewidened.editor_text); + let fresh = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide_width = { + let wide = text::text_blocks(&fresh, 0).unwrap().into_iter().next().unwrap().rect; + wide[2] - wide[0] + }; + for (save, reopen_as) in + [("incremental", reopen(&fresh)), ("full", Document::open(Arc::new(write_full(&fresh, &SaveOptions::default()).unwrap())).unwrap())] + { + let mut doc = reopen_as; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "{save}: the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + assert_eq!(block.reflow_text, "One Two", "{save}: the saved wrap did not remember both spaces: {:?}", block.reflow_text); + let mut doc = doc; + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "{save}: the second typed space was lost: {:?}", rewidened.editor_text); + } } /// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those diff --git a/crates/ui-egui/tests/play/narrow-widen-double-space.json b/crates/ui-egui/tests/play/narrow-widen-double-space.json new file mode 100644 index 0000000000..15fe835837 --- /dev/null +++ b/crates/ui-egui/tests/play/narrow-widen-double-space.json @@ -0,0 +1,15 @@ +{ + "name": "narrow then widen keeps two typed spaces", + "content": "BT /F1 24 Tf 72 700 Td (One Two) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 100, "y": 700}}, + {"expect_editor": {"contains": "One", "text": "One Two"}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"drag_edge": {"contains": "One", "dx": -220}}, + {"expect_editor": {"contains": "One", "text": "One\nTwo"}}, + {"drag_edge": {"contains": "One", "dx": 360}}, + {"expect_editor": {"contains": "One", "text": "One Two"}}, + {"shot": {"name": "narrow-widen-double-space"}} + ] +} diff --git a/docs/roadmap.md b/docs/roadmap.md index bad3da9a63..17594ee766 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -256,6 +257,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ca854bac90..056cb1bfe7 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_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/edit/src/tests.rs::narrowing_then_widening_keeps_a_typed_double_space", "crates/edit/src/tests.rs::narrowing_then_widening_a_japanese_word_adds_no_space", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/automation/tests/it/automation.rs::narrowing_then_widening_keeps_a_typed_double_space_through_tools", "crates/ui-egui/tests/play.rs::play_sessions_fetch_paragraph_bugs"] +notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, with the number of spaces that wrap consumed (a typed run stays a run; a Chinese or Japanese word split adds none) and that count survives an incremental save and a full rewrite (#2529), and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] id = "edit.paragraph-detection"