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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yousef Mojahid 2026-10-12 14:11:23 +03:00 committed by GitHub
parent 2796f3de0d
commit 186c1dc767
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 166 additions and 25 deletions

View File

@ -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.

View File

@ -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"

View File

@ -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 <</ActualText <FEFF{hex}>>> 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]

View File

@ -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 <</ActualText <FEFF{logical}>>> 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)

View File

@ -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 <</ActualText <FEFF{hex}>>> 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");

View File

@ -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 <</ActualText <FEFF{hex}>>> 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 <</ActualText <FEFF{hex}>>> 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::<Vec<_>>(), ["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 <</ActualText <FEFF{hex}>>> 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::<Vec<_>>(), ["בדיוק", "שלום", "בדיוק"]);
}
}
#[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 <</ActualText <FEFF{hex}>>> 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::<Vec<_>>(), ["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 \

View File

@ -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<usize>,
/// 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<usize>,
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<Option<(usize, usize, String)>> = 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<Vec<TextLine>, 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<Vec<TextLine>, 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<Vec<TextLine>, 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<TextLine>, 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<TextLine>, 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,

View File

@ -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

View File

@ -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();

View File

@ -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.

View File

@ -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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.<br>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.<br>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.<br>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.<br>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.<br>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<br>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.<br>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<br>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. |

View File

@ -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"