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:
parent
2796f3de0d
commit
186c1dc767
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue