diff --git a/ROADMAP.md b/ROADMAP.md index 1c15e8d24..f6adc8ea2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged. - **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged. - **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged. - **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c6d13ac0b..6cad0955f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -347,6 +347,43 @@ fn arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn arabic_ligature_actual_text_is_one_editable_word_through_save_reopen() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let dir = workdir("arabic-ligature-actual-text"); + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + // Synthetic glyph codes: A س, B م, X lam-alef. No installed Arabic font is needed. + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \ + 1 begincodespacerange <00> endcodespacerange 3 beginbfchar \ + <41> <0633> <42> <0645> <58> \ + endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"ToUnicode".to_vec(), Object::Ref(unicode))).unwrap(); + let content = "BT /F1 24 Tf 20 150 Td (B) Tj /Span <>> BDC (X) Tj EMC (A) Tj ET"; + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + std::fs::write(dir.join("arabic.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + + let mut a = auto(&dir); + let id = ok(&mut a, "doc_open", json!({"path":"arabic.pdf"}))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({"doc":id,"page":1})); + assert_eq!(lines["lines"].as_array().unwrap().len(), 1); + assert_eq!(lines["lines"][0]["text"], "سلام"); + assert_eq!(page_text(&mut a, id), ["سلام"]); + assert_eq!(ok(&mut a, "text_find", json!({"doc":id,"query":"سلام"}))["matches"].as_array().unwrap().len(), 1); + let render = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":id,"path":"saved.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1})), lines); + assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), render); + ok(&mut a, "text_edit", json!({"doc":re,"page":1,"paragraph":1,"text":"Replacement"})); + ok(&mut a, "doc_save", json!({"doc":re,"path":"edited.pdf"})); + let edited = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, edited), ["Replacement"]); + std::fs::remove_dir_all(dir).unwrap(); +} + /// Each of these writes to its required `path` and replaces an existing file there, so the MCP /// annotations must not tell clients the call is read-only or harmless (#130). #[test] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 06b920765..fce7d5e10 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2861,14 +2861,17 @@ fn group_move_budget_refusals_keep_the_original_page_selectable() { #[test] fn arabic_actual_text_keeps_typing_order_when_opened_for_editing() { - for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "שלום עולם", "Latin text"] { + for logical in ["مرحبا بالعالم", "سَلَام 123 (PDF)", "English مرحبا ١٢٣", "PDFمرحبا", "مرحبا", "שלום עולם", "Latin text"] + { let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect(); - let doc = text_page(&format!("/Span <>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC")); - for doc in [&doc, &reopen(&doc)] { - let lines = text::text_lines(doc, 0).unwrap(); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].text, logical, "ActualText is already in typing order"); - assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical); + for drawing in ["(abcd) Tj (efgh) Tj", "(X) Tj"] { + let doc = text_page(&format!("/Span <>> BDC BT /F1 12 Tf 72 700 Td {drawing} ET EMC")); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, logical, "ActualText is already in typing order"); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, logical); + } } } } @@ -2895,6 +2898,78 @@ fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { } } +/// Synthetic codes A س, B م and X a lam-alef glyph, drawn from left to right. +fn arabic_ligature_page(content: &str, composite: bool) -> Document { + let prefix = if composite { "00" } else { "" }; + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \ + 1 begincodespacerange <{prefix}00> <{prefix}FF> endcodespacerange 4 beginbfchar \ + <{prefix}20> <0020> <{prefix}41> <0633> <{prefix}42> <0645> <{prefix}58> \ + endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + let widths: Vec<_> = (32..=88).map(|c| if c == 32 { "250" } else { "500" }).collect(); + let font = if composite { + "<< /Type /Font /Subtype /Type0 /BaseFont /FakeArabic /Encoding /Identity-H /DescendantFonts [7 0 R] /ToUnicode 6 0 R >>".into() + } else { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /FakeArabic /FirstChar 32 /LastChar 88 /Widths [{}] /ToUnicode 6 0 R >>", widths.join(" ")) + }; + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + &stream("", content), + &font, + &stream("", &cmap), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /FakeArabic /CIDSystemInfo << /Registry (Synthetic) /Ordering (Identity) /Supplement 0 >> /DW 500 /W [32 [250]] >>", + ]) +} + +#[test] +fn arabic_ligature_actual_text_stays_on_its_line() { + for (ligature, composite) in [("لا", false), ("لأ", false), ("لإ", false), ("لآ", false), ("لَا", false), ("لا", true)] { + let hex: String = ligature.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let (left, glyph, right) = if composite { ("<0042>", "<0058>", "<0041>") } else { ("(B)", "(X)", "(A)") }; + let doc = arabic_ligature_page( + &format!("BT /F1 12 Tf 72 700 Td {left} Tj /Span <>> BDC [{glyph}] TJ EMC {right} Tj ET"), + composite, + ); + let expected = format!("س{ligature}م"); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected.as_str()]); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected); + } + } +} + +#[test] +fn multi_glyph_rtl_actual_text_keeps_its_own_reading_order() { + // Two encoded glyphs are a logical span, even if their replacement looks like a ligature. + for drawing in ["(XX) Tj", "(X) Tj (X) Tj", "[(X) (X)] TJ"] { + let doc = + arabic_ligature_page(&format!("BT /F1 12 Tf 72 700 Td (B) Tj /Span <>> BDC {drawing} EMC (A) Tj ET"), false); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["م", "لا", "س"]); + } +} + +#[test] +fn replacing_word_with_actual_text_ligature_removes_all_its_source_operators() { + let mut doc = arabic_ligature_page( + "BT /F1 12 Tf 72 700 Td (B) Tj /Span <>> BDC /Span BMC (X) Tj EMC EMC (A) Tj ET \ + BT /F1 12 Tf 72 600 Td (AB) Tj ET", + false, + ); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "سلام"); + text::replace_block(&mut doc, 0, 0, "Replacement").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["Replacement", "مس"]); + let bytes = page_content_bytes(&doc, 0); + let content = String::from_utf8_lossy(&bytes); + assert!(!content.contains("ActualText") && !content.contains("(X)") && !content.contains("(B)") && !content.contains("(A)"), "{content}"); +} + #[test] fn replacing_arabic_actual_text_removes_the_whole_original_drawing() { let logical = "مرحبا بالعالم"; diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 981f3c0d0..13233f71d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -458,6 +458,8 @@ fn fill_color(fill: &[Op]) -> [f64; 3] { struct Shown { op: usize, extra_ops: Vec, + /// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several). + glyph_count: usize, /// Advance in text space, including spacing and TJ adjustments. advance: f64, /// The text matrix where it starts (text space), and the state there and at its `BT`. @@ -553,6 +555,7 @@ fn interpret( let mut items = tail.into_iter(); if let Some(mut first) = items.next() { for shown in items { + first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); first.extra_ops.push(shown.op); first.extra_ops.extend(shown.extra_ops); first.rect = [ @@ -661,10 +664,12 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + let mut glyph_count = 0_usize; for p in &pieces { match p { Object::String(s) => { for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); match m.text_of(code) { Some(t) => text.push_str(&t), None => decodable = false, @@ -706,6 +711,7 @@ fn interpret( out.push(Shown { op: i, extra_ops: Vec::new(), + glyph_count, advance: x_text, tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), state: ts.clone(), @@ -754,7 +760,15 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let ops = pieces.parse().ops; let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size) - for s in shown { + for mut s in shown { + // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a + // separate logical line. Put its letters into visual order before joining the + // surrounding glyphs; the completed line is recovered to typing order below. + // Multi-glyph and mixed-direction replacements keep their authoritative order. + if s.logical_order && s.glyph_count == 1 && s.text.chars().all(crate::bidi::is_rtl) { + s.text = crate::bidi::to_visual(&s.text); + s.logical_order = false; + } // A line belongs to one stream piece. Within it, PDF producers often put every word // in its own BT/ET: geometry and text state, not object boundaries, define a line. let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9444594d7..15bf2e059 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,7 +5,7 @@ > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: preserve Arabic ActualText typing order; earlier: M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; earlier: exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: inline Arabic ActualText ligatures join their word; earlier: preserve Arabic ActualText typing order; M3: Windows' Animation effects setting is followed, and Preferences has Animation effects; exclusive image-crop resize follow-up; M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7: local GoToR links and confined automation candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; earlier: M10, printing on the web through the browser's print dialog, gap 4, rank 1 65–120 → 50–90 h) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -93,6 +93,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -151,6 +152,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M7: single-glyph RTL ActualText ligatures join their word in Edit text. Regressions cover marks, two-byte codes, replacement and save/reopen; multi-glyph logical spans retain their order. Estimates unchanged. | | 2026-10-12 | minor | M5 #1398: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages. Estimates unchanged. | | 2026-10-12 | minor | M12: text-only auto-tag Apply writes real structure and parent-tree links, with undo and Tags-panel review; mixed content remains unsupported. Estimates unchanged. | | 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. |