From 186c1dc7676bb750566bc590a1d8f97201730bf2 Mon Sep 17 00:00:00 2001 From: Yousef Mojahid <156470973+YouseifUS@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:11:23 +0300 Subject: [PATCH] M7: preserve Arabic text order in copy, search and editing (#1369) * M7: preserve Arabic text order when copying and editing Record per-line ActualText for shaped added text and preserve its logical order in Edit text. Cover mixed scripts, marks, copy/search, retyping and save/reopen; retain the drawing golden. Assisted-by: Codex * Edit text: keep only right-to-left ActualText out of line joining and bidi recovery Word and Chrome wrap Latin ligatures in /ActualText spans mid-word; splitting lines at every span broke such words into pieces in Edit Text. Only spans whose ActualText has strong right-to-left characters keep their own line and typing order now. Regression: a Latin fi ligature span stays on its line. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + contributors/people.toml | 3 ++ crates/automation/tests/automation.rs | 54 +++++++++++++++++++++++++++ crates/edit/src/added.rs | 7 +++- crates/edit/src/added/arabic_tests.rs | 11 +++++- crates/edit/src/tests.rs | 54 +++++++++++++++++++++++++++ crates/edit/src/text.rs | 39 +++++++++++-------- crates/fonts/README.md | 2 +- crates/ui-egui/src/editing.rs | 2 +- docs/editing-existing-text.md | 5 +++ docs/roadmap.md | 5 ++- parity/acrobat-features.toml | 8 ++-- 12 files changed, 166 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b3675776..00e14572 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. - **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. - **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged. - **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index b8aae3c0..9bf6fd9f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.YouseifUS] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f669fda6..abcf8e86 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -233,6 +233,60 @@ fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen() { + if pdfcraft_fonts::document_arabic_font().is_none() { + eprintln!("set CRAFT_FONTS_DIR to exercise added Arabic text"); + return; + } + let dir = workdir("arabic-copy-order"); + let mut a = auto(&dir); + let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); + let original = "سَلَام 123 (PDF) - اختبار الكتابة العربية"; + ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":original,"at":[40,50],"width":500,"size":16,"align":"right"})); + assert_eq!(page_text(&mut a, id), [original]); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"], original); + let found = ok(&mut a, "text_find", json!({"doc":id,"query":original})); + assert_eq!(found["matches"].as_array().unwrap().len(), 1); + let retyped = "مُرَاجَعَة PDF رقم ١٢٣"; + ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"text":retyped})); + ok(&mut a, "doc_save", json!({"doc":id,"path":"retyped.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"retyped.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, re), [retyped]); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":re,"page":1}))["lines"][0]["text"], retyped); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let dir = workdir("arabic-actual-text"); + let logical = "سَلَام 123 (PDF)"; + let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let content = format!("/Span <>> BDC BT /F1 24 Tf 20 150 Td (abcd) Tj (efgh) Tj ET EMC"); + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + 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"][0]["text"], logical); + 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!(ok(&mut a, "text_lines", json!({"doc":edited,"page":1}))["lines"][0]["text"], "Replacement"); + 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/added.rs b/crates/edit/src/added.rs index 67b2059b..42f78671 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -536,6 +536,10 @@ fn draw_laid_shaped( let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() { + // Shaped glyphs are painted in visual order. Keep the original line's typing order + // explicitly, including marks, ligatures, digits and mixed-script punctuation. + let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); + out.extend(format!("/Span <>> BDC\n").bytes()); let spaces = line.matches(' ').count(); // Justified: the spaces of every line but the last stretch to fill the box. let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; @@ -576,6 +580,7 @@ fn draw_laid_shaped( } } } + out.extend_from_slice(b"EMC\n"); } out.extend_from_slice(b"ET\n"); Ok(()) @@ -935,7 +940,7 @@ fn validate(c: &Content) -> Result<(), EditError> { let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() { t.text.split('\n').find_map(|line| { line.chars() - .find(|c| !shaped_arabic(*c) && !(is_thai(*c) && pdfcraft_fonts::thai_has(*c)) && !pdfcraft_fonts::win_ansi_encodable(*c)) + .find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c))) }) } else { first_undrawable(t) diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index f2c04cc2..4321412b 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -152,7 +152,8 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { assert_eq!(fonts.len(), 1); // The injected outlines are fixed; only the descriptor's family comes from craft-fonts. // Check that metadata separately, then normalize those two fields to the original golden. - // The drawing bytes and semantic object fields must match in both font build modes. + // ActualText is new metadata: check each line's value and remove only its added wrappers + // before hashing. Drawing bytes and font objects still match the original golden exactly. let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family); for (_, font) in fonts.iter() { let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap(); @@ -166,6 +167,14 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { .unwrap(); } let mut bytes = out.clone(); + for (line, ..) in &laid { + let hex: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let header = format!("/Span <>> BDC\n").into_bytes(); + let start = bytes.windows(header.len()).position(|w| w == header).expect("each line has its logical text"); + bytes.drain(start..start + header.len()); + let end = bytes.windows(4).position(|w| w == b"EMC\n").expect("each ActualText span closes"); + bytes.drain(end..end + 4); + } pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes); bytes.extend(golden_snapshot(&doc)); assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "small-item output changed"); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f0da637e..1d7c1cd8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2753,6 +2753,60 @@ 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"] { + 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); + } + } +} + +#[test] +fn latin_ligature_actual_text_stays_on_its_line() { + // Word and Chrome wrap ligatures in ActualText spans mid-word ("fi" drawn, "fi" meant). + let hex: String = "fi".encode_utf16().map(|u| format!("{u:04X}")).collect(); + let doc = text_page(&format!("BT /F1 12 Tf 72 700 Td (de) Tj /Span <>> BDC (X) Tj EMC (ned well) Tj ET")); + 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::>(), ["defined well"]); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, "defined well"); + } +} + +#[test] +fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { + let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect(); + let doc = hebrew_page(&format!("BT /F1 12 Tf 72 700 Td (EDCBA) Tj /Span <>> BDC (FGHD) Tj EMC (EDCBA) Tj ET")); + 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::>(), ["בדיוק", "שלום", "בדיוק"]); + } +} + +#[test] +fn replacing_arabic_actual_text_removes_the_whole_original_drawing() { + let logical = "مرحبا بالعالم"; + let hex: String = logical.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let mut doc = text_page(&format!( + "/Span <>> BDC BT /F1 12 Tf 72 700 Td (abcd) Tj (efgh) Tj ET EMC \ + BT /F1 12 Tf 72 600 Td (Keep this line) Tj ET" + )); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, logical); + 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", "Keep this line"]); + let bytes = page_content_bytes(&doc, 0); + let content = String::from_utf8_lossy(&bytes); + assert!(!content.contains("ActualText") && !content.contains("(abcd)") && !content.contains("(efgh)")); +} + fn mixed_region_page() -> Document { let mut doc = text_page( "BT /F2 1 Tf 18 0 0 18 123 700 Tm (TITLE) Tj ET \ diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index e8b5d6cf..8d469012 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -52,6 +52,9 @@ pub struct TextLine { origin: Origin, /// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph. scope: Option, + /// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi + /// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual. + logical_order: bool, } impl TextLine { @@ -466,6 +469,7 @@ struct Shown { italic: bool, decodable: bool, scope: Option, + logical_order: bool, } /// The graphics state carried from one of a page's content streams to the next: the streams @@ -518,18 +522,16 @@ fn interpret( let mut actual: Vec> = Vec::new(); for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { - b"BMC" | b"BDC" => { - if marked.len() < 256 { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); - } + b"BMC" | b"BDC" if marked.len() < 256 => { + // Marked text paragraphs/headings are authoritative boundaries. Ignore + // artifacts and inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); + let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { + Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), + _ => None, + }); + actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); } b"EMC" => { marked.pop(); @@ -548,6 +550,10 @@ fn interpret( ]; } first.extra_ops.extend([open, i]); + // Only right-to-left ActualText is already in typing order and must stay + // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures + // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); first.text = logical; first.start_x = first.rect[0]; first.end_x = first.rect[2]; @@ -706,6 +712,7 @@ fn interpret( italic: m.italic, decodable, scope: marked.last().copied().flatten(), + logical_order: false, }); } _ => {} @@ -737,7 +744,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); let joins = last.is_some_and(|(piece, bt, base, end, size)| { piece == si - && lines.last().is_some_and(|l| l.scope == s.scope) + && lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -773,6 +780,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro face: s.face.clone(), stream: si, scope: s.scope, + logical_order: s.logical_order, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -790,7 +798,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro last = Some((si, s.bt, s.baseline, s.end_x, s.size.max(1.0))); } // Content streams draw right-to-left lines in visual order; the editor works in typing order. - for l in &mut lines { + for l in lines.iter_mut().filter(|l| !l.logical_order) { l.text = crate::bidi::to_logical(&l.text); } // Lines of only spaces aren't editable text. @@ -815,7 +823,7 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool { /// (`bt`, baseline, end x, size). fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) { let joins = last.is_some_and(|(bt, base, end, size)| { - lines.last().is_some_and(|l| l.scope == s.scope) + lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) && (s.text.trim().is_empty() @@ -850,6 +858,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) face: s.face.clone(), stream, scope: s.scope, + logical_order: s.logical_order, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/fonts/README.md b/crates/fonts/README.md index e2785fc1..76fd89dd 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -45,7 +45,7 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: default `document_japanese_font` / `japanese_glyph` APIs still use regular document Mincho. - `document_arabic_font` / `shape_arabic` / `arabic_glyph`: the first `Arab` face, shaped with harfrust (joining forms, ligatures, mark positions) into clusters for Arabic text written into - PDFs; the editor draws each cluster as one Type 3 glyph with a ToUnicode entry. Without the face + PDFs; the editor draws each cluster as one Type 3 glyph with a ToUnicode entry and per-line ActualText for logical copy/search order. Without the face they return `GlyphError::NoFont` and the editor reports a clear error. - `document_japanese_fonts_for_style`: every `Jpan` face in that order of preference, so the editor can use the first one that has all of the replacement's glyphs (the faces differ in diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ce7799dd..6410898c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -49,7 +49,7 @@ impl PdfCraftApp { } else if let Some(form_before) = form_before && doc.info.pages.len() == page_count_before && doc.xfa.is_some() == had_xfa - && !doc.xfa.is_some() + && doc.xfa.is_none() && is_form_edit(&edit) { let mut changed_pages = std::collections::BTreeSet::new(); diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 525f9939..ed32c35b 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -27,6 +27,11 @@ same properties; invalid or overlapping ranges fail atomically. Untouched source survives. OpenType shaping uses the selected/source face's substitution and positioning rules, preserves marks and ligatures, and applies Unicode bidi ordering for a single face. Logical text remains available after save/reopen through ToUnicode and ActualText. +Added Arabic text records ActualText per shaped line for copying and searching in typing order. +ActualText already supplies typing order, so the editor preserves it without applying +a second bidi reversal; adjacent visual glyph runs still recover their own reading order. +Only right-to-left ActualText is kept apart this way: left-to-right spans, such as the Latin +ligatures (`fi`, `fl`) Word and Chrome wrap in ActualText mid-word, join their line as before. For headless editing, use `text_font_files` → `text_font_import` → `text_fonts` and pass a resource to `text_edit.font_resource`. `text_edit.ranges` holds selected range formatting. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bd266ad..a5e9142a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **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: 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) @@ -86,7 +86,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | 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. 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 (#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. 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. | | 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: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | | 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | | 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e80e400e..77026be8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1974,8 +1974,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace" @@ -1998,8 +1998,8 @@ milestone = "M7" status = "partial" commands = [] tools = ["text_lines", "text_paragraphs", "text_edit"] -evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip"] -notes = "Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned" +evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip", "crates/edit/src/tests.rs::arabic_actual_text_keeps_typing_order_when_opened_for_editing", "crates/automation/tests/automation.rs::arabic_actual_text_stays_logical_through_editor_tools_and_save_reopen", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] +notes = "Added Arabic copy/search keeps typing order through per-line ActualText. Arabic/Hebrew ActualText is preserved in typing order rather than reordered twice; font-independent tests cover marks, numbers, Latin, reopening and complete replacement. This does not add Arabic fallback fonts or bidi caret/selection. Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK. Also (#1116): bounded single-face existing-text engine coverage now exists (Arabic marks/ligatures/mixed bidi and explicit full-face Chinese subset completion), with private keyboard/save/reopen checks. General mixed-font/style contextual runs, bidi caret/selection, legacy programs and release font availability remain incomplete, so broader feature remains planned" [[feature]] id = "edit.rotated-text-editing"