From c7e8d127afb9ac22e6248eeebbc838bf6b2aa2d7 Mon Sep 17 00:00:00 2001 From: xullul <32987931+xullul@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:29:47 +0700 Subject: [PATCH] M7: edit grouped Thai text while preserving source formatting Add: - Prepare supported Form placements as original page operators through the existing-text UI and text_prepare, with shared-form isolation and undo/redo. - Repair narrowly identified Thai exporter maps on copied page fonts, recording repairs without changing the embedded source glyph programs. Change: - Reuse legacy Thai Regular/Bold glyph codes and same-face punctuation subsets; retain point size, spacing, horizontal scale, rise, colour and paint settings. - Keep cross-family Thai replacement explicit and write matching bundled Thai subsets through the existing bounded outline path rather than static embedding. - Keep replacement-only menu choices out of the source-font preview atlas so opening an ordinary paragraph does not wait an extra frame for unused fonts. - Refuse unsafe compositing, tagged or malformed groups, and missing contextual glyphs without losing the user's document or changing other placements. Chores: - Document the editing limits and headless workflow; keep text-edit parity partial. Builds on dekkeng's Thai fallback work in #2811 (fba0a29a) and mtlcl410's Form Ungroup implementation in #3067 (6eafca9c), adapted to the current module layout. The Ungroup menu and older UI font-priority changes are not included. Refs #3185, #2964 and #2142. Synthetic regressions cover source-font preservation, shared forms, unsafe refusals, layout, undo/redo, rendering and save/reopen. Validation: library suites 1,675 passed; automation integration 331 passed; document-font integration 2 passed; editor flows 248 passed. Existing ignored tests remain. Formatting, scoped strict Clippy, layers, assets, parity and wasm checks passed. The unchanged opacity regression failed before the choice-only atlas correction and passed afterwards. --- ROADMAP.md | 3 + crates/automation/README.md | 5 + crates/automation/src/dispatch.rs | 9 + crates/automation/src/tools.rs | 7 +- crates/automation/tests/it/automation.rs | 115 ++++ crates/edit/src/added.rs | 1 + crates/edit/src/added/draw.rs | 52 ++ crates/edit/src/images.rs | 2 +- crates/edit/src/tests.rs | 228 +++++++ crates/edit/src/text.rs | 3 +- crates/edit/src/text/blocks.rs | 2 +- crates/edit/src/text/editor.rs | 41 +- crates/edit/src/text/fallback.rs | 8 +- crates/edit/src/text/forms.rs | 192 ++++++ crates/edit/src/text/forms/expand.rs | 576 ++++++++++++++++++ crates/edit/src/text_regions.rs | 7 +- crates/edit/src/text_regions/styled.rs | 528 +++++++++++++--- crates/engine/src/document.rs | 14 + crates/engine/src/edit.rs | 4 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 9 + crates/engine/src/history.rs | 1 + crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/page_input.rs | 7 + .../ui-egui/src/tools/edit_text_ui/layout.rs | 4 +- docs/editing-existing-text.md | 35 ++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 6 +- 28 files changed, 1768 insertions(+), 101 deletions(-) create mode 100644 crates/edit/src/text/forms.rs create mode 100644 crates/edit/src/text/forms/expand.rs diff --git a/ROADMAP.md b/ROADMAP.md index 820ceb0179..988be90914 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged) + > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) @@ -230,6 +232,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-05 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing. Legacy Thai keeps the original font programs, Regular/Bold and text settings; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. diff --git a/crates/automation/README.md b/crates/automation/README.md index db5b988394..9c31754e0d 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -204,6 +204,11 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). +`text_prepare` makes supported grouped Form text editable on one page, as entering Edit text +in the GUI does. It keeps the group's original glyph operators and shared source objects, +records preparation/map repairs and is undoable. List `text_paragraphs` afterwards; its indices +belong to the prepared page. Unsafe compositing and malformed/tagged groups stay intact. + ### Superscript and subscript normalization `text_script` changes characters to their Unicode superscript or subscript forms; it does not diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index ebe2a93526..0d8c6e2333 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -480,6 +480,15 @@ impl Automation { self.apply(&a, Edit::EditPageImage { page, index, change })? } } + "text_prepare" => { + let page = self.page(&a)?; + let preparation = self.doc(&a)?.text_preparation(page).map_err(ToolError::Failed)?; + let mut result = self.apply(&a, Edit::PrepareText { page })?; + result["prepared_groups"] = json!(preparation.expanded); + result["repaired_fonts"] = json!(preparation.repaired_fonts); + result["refused_groups"] = json!(preparation.refused); + result + } "text_paragraphs" => { let page = self.page(&a)?; let doc = self.doc_to_copy(&a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index af5146377a..c58d803284 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1581,9 +1581,12 @@ pub fn tools() -> Vec { t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.") .cmd("edit.edit_text") .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])), - t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.") + t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. When available, builtin:thai explicitly selects bundled Noto Sans Thai for a paragraph or range; preview_only faces cannot be selected.") .ro().cmd("edit.edit_text") .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), + t("text_prepare", "Prepare grouped text", "Make existing text inside supported Form XObjects directly editable on one page. Replaces placements with their original operators and resources, preserving position, clipping and other pages. Refuses unsafe transparency, tags or malformed content. Adds no text or cover boxes. Returns prepared_groups, repaired_fonts and refused_groups. Undoable; list text_paragraphs again afterwards.") + .cmd("edit.edit_text") + .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.") .ro() .cmd("edit.edit_text") @@ -1597,7 +1600,7 @@ pub fn tools() -> Vec { .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, - "font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." }, + "font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or bundled document-font choice from text_fonts (builtin:thai selects the bundled Thai face); also accepts text_font_import resources." }, "ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." }, "text": { "type": "string" }, "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 96649c8e80..21c958b52e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11428,3 +11428,118 @@ fn comments_copy_and_paste_between_documents_through_tools() { assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_)))); assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); } + +#[test] +fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap(); + let mut a = auto(dir); + let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + let before = page_text(&mut a, doc); + let fonts = ok(&mut a, "text_fonts", json!({"doc":doc,"page":1})); + let choice = fonts["fonts"].as_array().unwrap().iter().find(|f| f["resource"] == "builtin:thai"); + let args = json!({"doc":doc,"page":1,"paragraph":1,"text":"น้ำ กิ่ กี้ ผู้ใช้","font_resource":"builtin:thai"}); + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(choice.is_none()); + assert!(a.call("text_edit", &args).is_err()); + assert_eq!(page_text(&mut a, doc), before); + return; + } + assert_eq!(choice.unwrap()["preview_only"], false); + ok(&mut a, "text_edit_layout", args.clone()); + assert_eq!(page_text(&mut a, doc), before); + ok(&mut a, "text_edit", args); + let edited = page_text(&mut a, doc); + assert!(edited[0].contains("น้ำ กิ่ กี้ ผู้ใช้")); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(page_text(&mut a, doc), before); + ok(&mut a, "edit_redo", json!({"doc":doc})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened), edited); + assert!(a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"})).is_err()); + assert_eq!(page_text(&mut a, reopened), edited); + let image = a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":144})).unwrap(); + assert!(image.iter().any(|c| matches!(c, Content::Png { .. }))); +} + +#[test] +fn grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + let mut source = Document::open(Arc::new(fixture(2))).unwrap(); + let old = "ข้อความเดิม น้ำ ผู้ใช้"; + let new = "ข้อความแก้ไข น้ำ ผู้ใช้"; + let mut cmap = String::from("begincmap 1 begincodespacerange <00> endcodespacerange\n"); + cmap.push_str(&format!("{} beginbfchar\n", old.chars().count())); + for (i, ch) in old.chars().enumerate() { + cmap.push_str(&format!("<{:02x}> <{:04x}>\n", 65 + i, u32::from(ch))); + } + cmap.push_str("endbfchar endcmap"); + let unicode = source.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+LegacyThai")); + font.set(b"FirstChar".to_vec(), Object::Int(65)); + font.set(b"LastChar".to_vec(), Object::Int(65 + old.chars().count() as i64 - 1)); + font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(300); old.chars().count()])); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + let font = source.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(font)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(300)])); + form.set(b"Resources".to_vec(), Object::Dict(resources)); + let codes: String = (0..old.chars().count()).map(|i| format!("{:02x}", 65 + i)).collect(); + let form = source.add(Object::Stream(Stream::flate(form, format!("BT /F1 14 Tf 20 220 Td <{codes}> Tj ET").as_bytes()))); + for page in [ObjRef::new(4, 0), ObjRef::new(6, 0)] { + let mut xobjects = Dict::new(); + xobjects.set(b"Body".to_vec(), Object::Ref(form)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = source.add(Object::Stream(Stream::flate(Dict::new(), b"q /Body Do Q"))); + source + .update_dict(page, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + } + std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(dir); + let id = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + let before = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0); + ok(&mut a, "text_prepare", json!({"doc":id,"page":1})); + assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before, "preparing must not change pixels"); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["paragraphs"][0]["text"], old); + let args = json!({"doc":id,"page":1,"paragraph":1,"text":new,"font_resource":"builtin:thai"}); + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(a.call("text_edit", &args).is_err()); + return; + } + ok(&mut a, "text_edit", args); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"].as_str().unwrap().trim(), new); + assert_eq!(page_text(&mut a, id)[1], old, "a shared form's other page must not change"); + let after = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + assert_ne!(after, before); + ok(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before); + ok(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0); + ok(&mut a, "edit_redo", json!({"doc":id})); + ok(&mut a, "edit_redo", json!({"doc":id})); + ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), after); + assert_eq!(page_text(&mut a, reopened), [new, old]); + assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 0, "editing existing text must not create Add Text objects"); +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index a603a37060..4abe4a02eb 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -23,6 +23,7 @@ mod layout; mod store; mod types; +pub(crate) use draw::thai_glyph_resources; #[cfg(test)] use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; #[cfg(test)] diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 9404f939b1..03b00fe392 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -386,3 +386,55 @@ pub(super) fn params(c: &Content) -> Dict { } d } + +/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline +/// writer instead of trying to embed the matching craft-fonts variable program as a static face. +pub(crate) fn thai_glyph_resources( + doc: &mut Document, + fonts: &mut Dict, + shaped: &pdfcraft_fonts::source_shaping::ShapedRun, +) -> Result)>, EditError> { + if shaped.glyphs.len() > 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + let mut outlines = HashMap::new(); + let mut clusters = Vec::new(); + let mut index = HashMap::new(); + let mut indices = Vec::new(); + for glyph in &shaped.glyphs { + let id = glyph.id.to_u32(); + let key = (ShapedScript::Thai, id); + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) { + entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?); + } + let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| { + let at = clusters.len(); + let width = outlines.get(&key).map_or(0.0, |g| g.width); + clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() }); + at + }); + indices.push(at); + } + // Validate all outlines before adding resources. Type 3 character codes are single bytes; + // shard long rows and return the appropriate resource/code for every positioned glyph. + let mut names = Vec::new(); + for chunk in clusters.chunks(GLYPHS_PER_FONT) { + let refs: Vec<_> = chunk.iter().collect(); + let font = type3_shaped(doc, &refs, &outlines); + doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?; + let mut name = format!("PCThai{}", font.num); + while fonts.contains(name.as_bytes()) { + name.push('_'); + } + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + names.push(name); + } + indices + .into_iter() + .map(|at| { + let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; + let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; + Ok((name.clone(), vec![code])) + }) + .collect() +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 72656fa1bb..b4ecd1f74f 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -49,7 +49,7 @@ pub struct PageImage { /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the /// operators of the page's streams joined ([`Pieces`]). pub stream: usize, - op: usize, + pub(crate) op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], clips: Vec, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 22fbd6e8ae..d8f0ca16ee 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10696,3 +10696,231 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } } + +#[test] +fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+UnavailableThai")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + f.set(b"FirstChar".to_vec(), Object::Int(65)); + f.set(b"LastChar".to_vec(), Object::Int(65)); + f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(600)])); + }) + .unwrap(); + let before = text::text_lines(&doc, 0).unwrap(); + let choice = text::text_editor_fonts(&doc, 0).unwrap().into_iter().find(|f| f.resource == "builtin:thai"); + let style = text::BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() }; + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(choice.is_none()); + assert!(text::rewrite_block(&mut doc, 0, 0, Some("น้ำ"), &style).is_err()); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, before[0].text); + return; + } + assert!(choice.is_some(), "the document-font menu must offer a bundled Thai replacement"); + let error = text::rewrite_block(&mut doc, 0, 0, Some("นน"), &text::BlockStyle::default()).unwrap_err().to_string(); + assert!(error.contains("UnavailableThai") && error.contains("Noto Sans Thai"), "{error}"); + let draft = "น้ำ กิ่ กี้ ผู้ใช้"; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, before[1].text); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), before[1].baseline()); + text::rewrite_block(&mut doc, 0, 0, Some("น้ำ ปู่"), &text::BlockStyle::default()).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "น้ำ ปู่"); + // A selected range uses the same explicit choice; neighbouring source runs keep their face. + let mut selected = text_page("BT /F1 16 Tf 50 700 Td (Ab) Tj ET"); + let style = text::BlockStyle { + ranges: vec![text::RangeStyle { + range: 0..1, + resource: Some("builtin:thai".into()), + family: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }], + ..Default::default() + }; + text::rewrite_block(&mut selected, 0, 0, Some("Ab"), &style).unwrap(); + let lines = text::text_lines(&reopen(&selected), 0).unwrap(); + assert!(lines.iter().any(|l| l.text == "A" && l.base_font.contains("NotoSansThai"))); + assert!(lines.iter().any(|l| l.text == "b" && l.base_font == "Helvetica")); +} + +#[test] +fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() { + let Some(_) = pdfcraft_fonts::document_thai_font() else { return }; + // Synthetic source subset: only one Thai letter is encoded. No binary fixture or font + // is committed; the complete, openly licensed matching face is the optional build input. + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + let neighbour = text::text_lines(&doc, 0).unwrap()[1].clone(); + // More than 240 distinct logical clusters requires several single-byte Type 3 resources. + let long: String = + "กขคงจฉชซญดตถทนบปผฝพฟมยรลวศษสหฬอฮ".chars().flat_map(|c| ["ิ่", "ิ้", "ี่", "ี้", "ึ่", "ึ้", "ื่", "ื้"].map(move |marks| format!("{c}{marks}"))).collect(); + let draft = format!("{long} ผู้ใช้ ปู่ ๑๒๓"); + let style = text::BlockStyle { width: Some(14_400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [50.0, 700.0]); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, neighbour.text); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), neighbour.baseline()); + let next = "Hello ผู้ใช้ น้ำ ๔๕๖"; + text::rewrite_block(&mut doc, 0, 0, Some(next), &text::BlockStyle::default()).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next); +} + +#[test] +fn grouped_source_text_preparation_edits_original_operators_without_changing_shared_forms() { + let mut doc = page_with_forms("q 1 0 0 1 12 20 cm /Body Do Q", &[("Body".into(), "BT /F1 14 Tf 40 500 Td (Original body) Tj ET".into())]); + let original_form = doc.get(pdfcraft_cos::ObjRef::new(7, 0)).clone(); + let first = pdfcraft_model::pages(&doc)[0].clone(); + let second = doc.add(Object::Dict(first.dict.clone())); + doc.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first.obj), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + assert!(!text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text.contains("Original body"))); + let prepared = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(prepared.expanded, 1); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == "Original body").unwrap(); + assert!((block.rect[0] - 52.0).abs() < 0.01, "placement must survive preparation: {block:?}"); + rewrite_block(&mut doc, 0, index, Some("Changed body"), &BlockStyle::default()).unwrap(); + assert!( + !text::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).unwrap().iter().any(|l| l.text.contains("Original body")), + "old glyphs must be removed, not covered" + ); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "Changed body")); + assert!(text::reading_blocks(&doc, 1).unwrap().iter().any(|b| b.text == "Original body")); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(7, 0)), original_form); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 0); +} + +#[test] +fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact() { + for (page_ops, form_ops, extra) in [ + ("/Body Do", "BT /F1 14 Tf (body) Tj ET", "/Group << /S /Transparency /K true >>"), + ("/Body Do", "Q BT /F1 14 Tf (body) Tj ET", ""), + ("/Body Do", "BT /F1 14 Tf (body) Tj", ""), + ("10 10 m 20 20 l /Body Do S", "BT /F1 14 Tf (body) Tj ET", ""), + ("BT /F1 14 Tf /Body Do ET", "BT /F1 14 Tf (body) Tj ET", ""), + ("/Body Do", "/Body Do", "/Group << /S /Transparency >>"), + ] { + let mut doc = page_with_forms(page_ops, &[("Body".into(), form_ops.into())]); + if !extra.is_empty() { + let additions = build(&[&format!("<< {extra} >>")]); + let fields = additions.get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + let form = doc.get(pdfcraft_cos::ObjRef::new(7, 0)); + let Object::Stream(mut s) = form.as_ref().clone() else { panic!("fixture") }; + for (k, v) in fields.iter() { + s.dict.set(k.clone(), v.clone()); + } + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(s)); + } + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 0, "unsafe input {page_ops}, {form_ops}, {extra}"); + assert!(!result.refused.is_empty()); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); + } +} + +#[test] +fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen() { + // Remap existing OFL Inter outlines to synthetic Thai glyph slots; no proprietary font + // fixture is needed. Removing layout/cmap tables reproduces legacy PDF font subsetting. + let mut program = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + let count = u16::from_be_bytes(program[4..6].try_into().unwrap()) as usize; + let records: Vec<_> = program[12..12 + count * 16] + .as_chunks::<16>() + .0 + .iter() + .filter(|r| !matches!(&r[..4], b"cmap" | b"GSUB" | b"GPOS")) + .map(|r| r.to_vec()) + .collect(); + program[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (index, record) in records.iter().enumerate() { + program[12 + index * 16..28 + index * 16].copy_from_slice(record); + } + for bold in [false, true] { + let mut doc = text_page("BT /F1 17.5 Tf 0.42 Tc 1.1 Tw 86 Tz 1.5 Ts 2 Tr 0.6 w 0.2 0.3 0.4 rg 50 650 Td <00d601090119> Tj ET"); + let raw = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(raw)); + let mut descendant = Dict::new(); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); + descendant.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(65535), Object::Int(500)])); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar", + ))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type0")); + font.set(b"BaseFont".to_vec(), Object::name(if bold { "ABCDEF+THSarabunPSK-Bold" } else { "ABCDEF+THSarabunPSK" })); + font.set(b"Encoding".to_vec(), Object::name("Identity-H")); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(descendant)])); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font.clone())); + let before = text::text_lines(&doc, 0).unwrap()[0].clone(); + let fonts = page_font_programs(&doc); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กิ่ กี้ น้ำ"), &style).expect("existing source glyphs must remain editable"); + assert_eq!(edited.substituted, None, "keeping the source must never silently select Noto or another face"); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines[0].text, "กิ่ กี้ น้ำ"); + for after in &lines { + assert_eq!(after.font, before.font); + assert_eq!(after.base_font, before.base_font); + assert_eq!(after.bold, bold); + assert_eq!(after.size, before.size); + assert_eq!(after.color, before.color); + assert_eq!(after.baseline(), before.baseline()); + } + let mut settings = std::collections::BTreeMap::new(); + for op in pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops { + if matches!(op.op.as_slice(), b"Tc" | b"Tw" | b"Tz" | b"Ts" | b"Tr" | b"w") { + settings.insert(op.op.clone(), op.num(0).unwrap()); + } + let shows = if op.is("Tj") { + op.operands.first().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()) + } else if op.is("TJ") { + op.operands + .first() + .and_then(Object::as_array) + .is_some_and(|a| a.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty()))) + } else { + false + }; + if shows { + break; + } + } + for (op, expected) in [("Tc", 0.42), ("Tw", 1.1), ("Tz", 86.0), ("Ts", 1.5), ("Tr", 2.0), ("w", 0.6)] { + assert_eq!(settings.get(op.as_bytes()), Some(&expected), "{op} must survive in the saved PDF"); + } + let mut explicit = doc.clone(); + let chosen = BlockStyle { resource: Some("F1".into()), width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut explicit, 0, 0, Some("กี้ น้ำ"), &chosen).expect("selecting the existing source font from the menu must work too"); + assert_eq!(text::text_lines(&reopen(&explicit), 0).unwrap()[0].base_font, before.base_font); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict(), Some(&font)); + let Object::Stream(saved) = doc.get(raw).as_ref().clone() else { panic!("fixture font") }; + assert_eq!(saved.decoded().unwrap(), program); + assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3d1626a576..fd6f90e79f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -31,6 +31,7 @@ pub use session::TextSession; mod blocks; mod editor; mod fallback; +pub mod forms; mod interp; mod lines; mod rewrite; @@ -42,8 +43,8 @@ pub use blocks::{ BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; -use editor::layout_draft; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; +use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 48fe956f4f..a459073345 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -400,7 +400,7 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) pub struct BlockStyle { /// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style. pub ranges: Vec, - /// Explicit page font resource (from register_text_font or the page font inventory). + /// Explicit page font resource or bundled document-font choice from the page font inventory. pub resource: Option, /// A standard font family, bold, italic. pub family: Option<(crate::added::Family, bool, bool)>, diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index f7aa1f47bd..4f4abf3a43 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,15 +2,30 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; +use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession}; -/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and -/// are never installed, copied into assets, or used as a font for another document. +/// An embedded source font or an approved bundled document-font choice. +/// Source bytes stay in memory and are never installed or copied into another document. #[derive(Clone, Debug)] pub struct TextEditorFont { pub resource: String, pub face: String, pub bytes: Vec, + /// A selectable replacement, rather than a source/matching preview face. Merely listing + /// this choice must not reload an open editor's font atlas. + pub choice_only: bool, +} + +/// Reserved document-font choice for explicitly replacing text with the bundled Thai face. +/// This is a selector, never a font resource written into a PDF. +const BUILTIN_THAI_FONT: &str = "builtin:thai"; + +pub(super) fn builtin_thai_choice(fonts: &Dict) -> String { + let mut choice = BUILTIN_THAI_FONT.to_owned(); + while fonts.contains(choice.as_bytes()) { + choice.push('_'); + } + choice } pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, EditError> { @@ -32,6 +47,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result Result bool { font.name(b"Subtype") == Some(b"Type3") - && font - .name(b"Name") - .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) + && font.name(b"Name").is_some_and(|n| { + n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes() || n == b"PCThai" + }) } /// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs new file mode 100644 index 0000000000..71339af64b --- /dev/null +++ b/crates/edit/src/text/forms.rs @@ -0,0 +1,192 @@ +//! Make text in ordinary Form XObjects available to the existing paragraph editor. +//! +//! This replaces each supported placement's `Do`, never the shared form object. The same +//! source glyph operators become page content; no cover rectangle or replacement text box is +//! added. Unsupported compositing and malformed streams stay intact. + +mod expand; + +use pdfcraft_content::{Matrix, Pieces}; +use pdfcraft_cos::{Dict, Document, Object}; + +use super::{page_dict, rewritten_contents, splice}; +use crate::EditError; + +/// Result of preparing one page. Refused groups remain in the page, with an actionable reason. +#[derive(Clone, Debug, Default)] +pub struct Preparation { + pub expanded: usize, + pub refused: Vec, + pub repaired_fonts: usize, +} + +/// Prepare grouped source text atomically, within eight levels and 64 placements per page. +/// Shared forms and other pages are untouched. Preparation is a distinct undoable engine edit. +pub fn prepare(doc: &mut Document, page: usize) -> Result { + let mut next = doc.clone(); + let mut result = Preparation::default(); + let mut bytes_left = 16usize << 20; + let mut visits = 0usize; + let mut decode = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + for _ in 0..8 { + let p = page_dict(&next, page)?; + let streams = strict_streams(&next, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, b)| b.as_slice()).collect::>()); + let parsed = pieces.parse(); + if parsed.skipped > 0 { + return Err(EditError::Invalid("the page has unreadable content; grouped text was left unchanged".into())); + } + let ops = parsed.ops; + let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let space = expand::page_blend_space(&next, &p.dict); + let images: std::collections::BTreeMap<_, _> = + crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect(); + let mut replacements = std::collections::HashMap::new(); + let mut merged = resources; + let mut pending_path = false; + let mut in_text = false; + for (at, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => pending_path = true, + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => pending_path = false, + b"BT" => in_text = true, + b"ET" => in_text = false, + _ => {} + } + let Some(image) = images.get(&at) else { continue }; + visits = visits.saturating_add(1); + if visits > 64 { + return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + } + if pending_path || in_text { + result.refused.push(format!("{}: the group is drawn inside an unfinished path or text object", image.name)); + continue; + } + let translucent = expand::translucent_at(&next, &merged, &ops, at); + match expand::ungroup_ops(&next, &merged, image.name.as_bytes(), translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + Ok((inline, mut res, patterns)) => { + // Pure vector artwork needs no preparation. Nested text groups are visited + // on the next pass, with their inherited placement and clip still in force. + if !inline.iter().any(|o| matches!(o.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do")) { + continue; + } + let data_len = pdfcraft_content::serialize_ops(&inline).len(); + bytes_left = bytes_left.checked_sub(data_len).ok_or_else(|| { + EditError::Invalid("grouped page content exceeds the 16 MiB editing budget; the page was left unchanged".into()) + })?; + if !patterns.is_empty() { + let mut table = res.get(b"Pattern").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + for (name, value) in patterns { + table.set(name, Object::Ref(next.add(value))); + } + res.set(b"Pattern".to_vec(), Object::Dict(table)); + } + merged = res; + replacements.insert(at, inline); + result.expanded += 1; + } + Err(error) => result.refused.push(format!("{}: {error}", image.name)), + } + } + if replacements.is_empty() { + break; + } + let changed = splice(&pieces, &ops, |at| match replacements.remove(&at) { + Some(ops) => (ops, false), + None => (Vec::new(), true), + }); + let contents = rewritten_contents(&mut next, &streams, changed); + next.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(merged)); + })?; + } + result.refused.sort(); + result.refused.dedup(); + if result.expanded > 0 { + for reason in &result.refused { + next.note_repair(format!("Grouped text left intact: {reason}")); + } + result.repaired_fonts = repair_thai_fonts(&mut next, page)?; + next.note_repair(format!( + "Prepared {} grouped text placement(s) on page {} for direct source-text editing", + result.expanded, + page.saturating_add(1) + )); + *doc = next; + } + Ok(result) +} + +/// A font is copied into this page: repairing the map must never alter a shared form/font. +fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result { + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut count = 0; + let mut candidates = 0usize; + for (name, value) in fonts.clone().iter() { + let Some(mut font) = doc.resolve(value).as_dict().cloned() else { continue }; + if font.name(b"BaseFont").is_some_and(|n| String::from_utf8_lossy(n).contains("THSarabunPSK")) { + candidates += 1; + if candidates > 64 { + return Err(EditError::Invalid("too many legacy Thai font maps to repair safely; the page was left unchanged".into())); + } + } else { + continue; + } + let Some(map) = pdfcraft_fonts::pdf::recovered_thai_map(doc, &font) else { continue }; + // Append corrective bfchar entries after the original entries so valid Latin mappings + // remain. The original map's accidental oversized range is still overridden for Thai. + let Some(Object::Stream(tu)) = font.get(b"ToUnicode").map(|r| doc.resolve(r)).as_deref().cloned() else { continue }; + let old = pdfcraft_cos::DecodeBudget::new(1 << 20, 0).decode_strict(&tu)?; + let marker = b"endcmap"; + let Some(at) = old.windows(marker.len()).position(|w| w == marker) else { continue }; + let mut added = format!("\n{} beginbfchar\n", map.len()); + for (code, ch) in map { + added.push_str(&format!("<{code:04x}> <{:04x}>\n", u32::from(ch))); + } + added.push_str("endbfchar\n"); + let mut bytes = old.get(..at).unwrap_or_default().to_vec(); + bytes.extend_from_slice(added.as_bytes()); + bytes.extend_from_slice(old.get(at..).unwrap_or_default()); + let tu = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &bytes))); + font.set(b"ToUnicode".to_vec(), Object::Ref(tu)); + let object = doc.add(Object::Dict(font)); + fonts.set(name.clone(), Object::Ref(object)); + count += 1; + } + if count > 0 { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + doc.note_repair(format!( + "Recovered the damaged legacy THSarabunPSK Thai character map in {count} page font(s); original glyph programs were preserved" + )); + } + Ok(count) +} + +/// Editing cannot infer the state after a damaged page stream. Read all pieces strictly before +/// expanding a placement, rather than treating an undecodable piece as empty. +fn strict_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + let mut budget = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + let list = match page.get(b"Contents") { + None => Vec::new(), + Some(value) => match doc.resolve(value).as_ref() { + Object::Null => Vec::new(), + Object::Array(a) if a.len() <= 4096 => a.clone(), + Object::Array(_) => return Err(EditError::Invalid("too many page content streams to prepare safely".into())), + _ => vec![value.clone()], + }, + }; + list.into_iter() + .map(|value| { + let resolved = doc.resolve(&value); + let Object::Stream(stream) = resolved.as_ref() else { + return Err(EditError::Invalid("the page has a missing or unreadable content stream".into())); + }; + let bytes = budget.decode_strict(stream)?; + Ok((value, bytes)) + }) + .collect() +} diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs new file mode 100644 index 0000000000..27cbf1b48e --- /dev/null +++ b/crates/edit/src/text/forms/expand.rs @@ -0,0 +1,576 @@ +//! Existing-text preparation: draw a Form XObject's content in place of its `Do` (#2964, #3005). +//! Adapted from mtlcl410's Ungroup implementation in PR #3067, revision +//! `6eafca9caa4a4d2174a395b53914bc9e689097cc` (MIT OR Apache-2.0). +//! +//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own +//! content streams. Ungrouping copies the form's content into the page at the same place, +//! wrapped as `q cm re W n … Q`, with the form's resources merged into a copy of +//! the page's under names that don't clash. Its text then edits like any other. The form object +//! itself is left alone, so another page or placement that draws it is unchanged. +//! +//! Drawing a form inline paints the same pixels unless the form is a transparency group whose +//! compositing differs from painting its parts one by one: a knockout group, a group that uses +//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft +//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents +//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would +//! composite in the page's. Those are refused rather than changed, as is tagged form content (its structure link +//! would be lost) and content whose marked content or text objects don't balance (inlined, they +//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`. +//! +//! A pattern's matrix maps pattern space to the default space of the content stream that uses it +//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is +//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose +//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused. + +use std::collections::HashMap; + +use pdfcraft_content::{Matrix, Op, parse}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; + +use crate::EditError; + +/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above +/// any real form, and a bomb must not be copied into the page. +const MAX_FORM_BYTES: usize = 16 << 20; + +/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). +const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; + +/// The most distinct patterns one ungrouped form may select; each becomes a new object. +const MAX_MOVED_PATTERNS: usize = 256; + +/// A name map for one resource category: form name → name in the page's resources. +type Renames = Vec<(Vec, Vec)>; + +/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with +/// the form's merged in, and the pattern copies to add to the document, each under the name in +/// the merged `/Pattern` dictionary that the ops already use. +pub(crate) type Ungrouped = (Vec, Dict, Vec<(Vec, Object)>); + +/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state +/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a +/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). +/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, +/// then the CTM there). +pub(crate) fn ungroup_ops( + doc: &Document, + resources: &Dict, + name: &[u8], + translucent: bool, + page_space: Option<&Object>, + placement: Matrix, + budget: &mut DecodeBudget, +) -> Result { + let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; + let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; + let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; + if !is_name(doc, &s.dict, b"Subtype", b"Form") { + return Err(refuse("it is not a Form XObject")); + } + let numbers = |key: &[u8], n: usize| -> Option> { + let a = s.dict.get(key).map(|o| doc.resolve(o))?; + if a.as_array()?.len() != n { + return None; + } + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) + }; + let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?; + let matrix = match s.dict.get(b"Matrix") { + None => None, + Some(_) => Some(Matrix( + <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?) + .map_err(|_| refuse("its matrix is unreadable"))?, + )), + }; + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + if s.dict.get(b"Resources").is_some() && own.is_none() { + return Err(refuse("its resources cannot be read")); + } + // A form without its own resources uses the ones it is drawn with (PDF 1.1). + let form_res = own.clone().unwrap_or_else(|| resources.clone()); + + // A tagged form's content is a structure element's: drawn inline it would lose that link. + if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() { + return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags")); + } + let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency")); + if s.dict.get(b"Group").is_some() && group.is_none() { + return Err(refuse("its group dictionary cannot be read")); + } + if let Some(group) = &group { + if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { + return Err(refuse("it is a knockout transparency group")); + } + if translucent { + return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); + } + // Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS); + // inline they would composite in the page's. + if let Some(cs) = group.get(b"CS") + && !same_blend_space(doc, cs, page_space) + { + return Err(refuse("it is a transparency group that blends in a colour space of its own")); + } + } + + // Strictly: damaged data must not be copied in as a shorter form. + let data = budget.decode_strict(s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; + let parsed = parse(&data); + if parsed.skipped > 0 { + return Err(refuse("its content has parts that can't be read")); + } + let mut ops = parsed.ops; + { + // Check every form, including non-group forms, for recursive/unsupported content. + // The form itself is being read: drawing itself again counts as blending. + let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget }; + if blends_inside(doc, &form_res, &ops, 0, &mut walk) { + return Err(refuse("it has blend modes, recursive forms, or nested content that cannot be verified")); + } + } + + // Merge the form's resources into a copy of the page's, renaming what clashes. + let mut merged = resources.clone(); + let mut renames: Vec<(&[u8], Renames)> = Vec::new(); + if own.is_some() { + for cat in CATEGORIES { + let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; + if theirs.len() > 4096 { + return Err(refuse("too many resources")); + } + let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + if mine.len() > 4096 { + return Err(refuse("too many resources")); + } + let mut map = Renames::new(); + let mut n = 0u32; + for (key, value) in theirs.iter() { + if cat == b"ColorSpace" && matches!(key.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") && mine.get(key) != Some(value) { + return Err(refuse("it overrides a default colour space")); + } + if mine.get(key) == Some(value) { + continue; + } + let fresh = loop { + n = n.saturating_add(1); + let candidate = format!("PCUg{n}").into_bytes(); + if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() { + break candidate; + } + if n == u32::MAX { + return Err(refuse("too many resources")); + } + }; + mine.set(fresh.clone(), value.clone()); + map.push((key.to_vec(), fresh)); + } + merged.set(cat.to_vec(), Object::Dict(mine)); + if !map.is_empty() { + renames.push((cat, map)); + } + } + } + let renamed = |cat: &[u8], name: &[u8]| -> Option> { + renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone()) + }; + // A nested form, Type 3 font or tiling pattern without resources of its own looks its names + // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it + // would find the page's, which differ wherever a name was renamed (every form resource that + // isn't already the page's, the nested item's own entry included). + if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { + return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources")); + } + let mut depth = 0usize; + // Marked content and text objects must balance within the form's own stream: an extra EMC + // would close one the page opened, and one left open would take in the page's later content. + let mut marked = 0usize; + let mut in_text = false; + for op in &mut ops { + match op.op.as_slice() { + b"BDC" | b"BMC" => marked = marked.saturating_add(1), + b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?, + b"BT" if in_text => return Err(refuse("its content begins a text object inside another")), + b"BT" => in_text = true, + b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")), + b"ET" => in_text = false, + _ => {} + } + let slot: Option<(&[u8], usize)> = match op.op.as_slice() { + b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => { + let cat: &[u8] = match op.op.as_slice() { + b"Tf" => b"Font", + b"Do" => b"XObject", + b"gs" => b"ExtGState", + b"sh" => b"Shading", + _ => b"ColorSpace", + }; + Some((cat, 0)) + } + b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)), + b"BDC" | b"DP" => Some((&b"Properties"[..], 1)), + b"q" => { + if depth >= 4096 { + return Err(refuse("graphics state is too deeply nested")); + } + depth += 1; + None + } + b"Q" => { + depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?; + None + } + _ => None, + }; + if let Some((cat, i)) = slot + && let Some(operand) = op.operands.get_mut(i) + && let Object::Name(n) = operand + && let Some(to) = renamed(cat, n) + { + *operand = Object::Name(to); + } + if let Some((dict, _)) = &mut op.inline { + for key in [&b"CS"[..], b"ColorSpace"] { + if let Some(Object::Name(n)) = dict.get(key) + && let Some(to) = renamed(b"ColorSpace", n) + { + dict.set(key.to_vec(), Object::Name(to)); + } + } + } + } + + if marked > 0 || in_text { + return Err(refuse("its content leaves marked content or a text object open")); + } + + // Patterns keep their place relative to the shapes they fill: each one the form selects is + // drawn from a copy that also carries the placement. Nested forms and patterns inside + // patterns keep their own parents, so only this stream's selections change. + let mut patterns: Vec<(Vec, Object)> = Vec::new(); + let mut pattern_bytes = MAX_FORM_BYTES; + if placement != Matrix::IDENTITY { + let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut moved: Renames = Vec::new(); + let mut n = 0u32; + for op in &mut ops { + if !(op.is("scn") || op.is("SCN")) { + continue; + } + let Some(i) = op.operands.len().checked_sub(1) else { continue }; + let Some(Object::Name(from)) = op.operands.get(i) else { continue }; + // A name the resources don't have selects nothing, inline or not. + let Some(entry) = table.get(from).cloned() else { continue }; + let to = match moved.iter().find(|(f, _)| f == from) { + Some((_, to)) => to.clone(), + None => { + if moved.len() >= MAX_MOVED_PATTERNS { + return Err(refuse("it paints with too many patterns")); + } + let copy = + moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?; + if let Object::Stream(s) = © { + pattern_bytes = + pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies exceed the editing budget"))?; + } + let fresh = loop { + n = n.saturating_add(1); + let candidate = format!("PCUgPat{n}").into_bytes(); + if table.get(&candidate).is_none() { + break candidate; + } + if n == u32::MAX { + return Err(refuse("too many resources")); + } + }; + // Reserved here; the caller replaces it with the copy's reference. + table.set(fresh.clone(), Object::Null); + patterns.push((fresh.clone(), copy)); + moved.push((from.clone(), fresh.clone())); + fresh + } + }; + if let Some(operand) = op.operands.get_mut(i) { + *operand = Object::Name(to); + } + } + if !patterns.is_empty() { + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } + } + + // A form in a layer (/OC) stays in it: its content is marked with the same membership. + let layer = match s.dict.get(b"OC") { + None => None, + Some(oc) => { + let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone()); + let name = match existing { + Some(k) => k, + None => { + let fresh = (1..=u32::MAX) + .map(|n| format!("PCUgOC{n}").into_bytes()) + .find(|c| props.get(c).is_none()) + .ok_or_else(|| refuse("too many resources"))?; + props.set(fresh.clone(), oc.clone()); + merged.set(b"Properties".to_vec(), Object::Dict(props)); + fresh + } + }; + Some(name) + } + }; + + let num = |v: f64| Object::Real(v); + let mut out = Vec::new(); + if let Some(name) = &layer { + out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())])); + } + out.push(Op::new("q", Vec::new())); + if let Some(m) = matrix { + out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); + } + let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| refuse("invalid form bounds"))?; + let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); + out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); + out.push(Op::new("W", Vec::new())); + out.push(Op::new("n", Vec::new())); + out.extend(ops); + // A form discards its unfinished current path when returning to its caller. + out.push(Op::new("n", Vec::new())); + // A form ends with the state it was drawn with (the Do saves and restores it). + out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); + out.push(Op::new("Q", Vec::new())); + if layer.is_some() { + out.push(Op::new("EMC", Vec::new())); + } + Ok((out, merged, patterns)) +} + +/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead +/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its +/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy. +fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option { + let resolved = doc.resolve(entry); + let (mut dict, stream) = match &*resolved { + Object::Stream(s) => (s.dict.clone(), Some(s)), + Object::Dict(d) => (d.clone(), None), + _ => return None, + }; + let own = match dict.get(b"Matrix") { + None => Matrix::IDENTITY, + Some(m) => { + let m = doc.resolve(m); + if m.as_array()?.len() != 6 { + return None; + } + let nums: Vec = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect(); + Matrix::from_operands(&nums)? + } + }; + let moved = own.then(&placement); + if !moved.0.iter().all(|v| v.is_finite()) { + return None; + } + dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect())); + match stream { + Some(s) if s.raw.len() > MAX_FORM_BYTES => None, + Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })), + None => Some(Object::Dict(dict)), + } +} + +/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending. +const MAX_NESTING: usize = 8; + +/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest +/// count as blending, so a file can't make the check run for ever. +const MAX_NESTED_READS: usize = 64; + +/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a +/// form drawing itself), the answer for each form with resources of its own (its answer doesn't +/// depend on who draws it), and how many reads it has made. +struct BlendWalk<'a> { + path: Vec, + known: HashMap, + reads: usize, + budget: &'a mut DecodeBudget, +} + +/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from +/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. +fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { + let (cat, i): (&[u8], usize) = match op.op.as_slice() { + b"Do" => (b"XObject", 0), + b"Tf" => (b"Font", 0), + b"scn" | b"SCN" => match op.operands.len().checked_sub(1) { + Some(i) => (b"Pattern", i), + None => return false, + }, + _ => return false, + }; + let Some(Object::Name(name)) = op.operands.get(i) else { return false }; + let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { + return false; + }; + let dict = match &*doc.resolve(&entry) { + Object::Stream(s) => s.dict.clone(), + Object::Dict(d) => d.clone(), + _ => return false, + }; + let drawn = match cat { + b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"), + b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"), + // A shading pattern has no content of its own. + _ => int_of(doc, &dict, b"PatternType") == Some(1), + }; + drawn && dict.get(b"Resources").is_none() +} + +/// Whether `ops` blend through a used graphics state, nested form or pattern. Unreadable, +/// recursive or over-budget nested content is treated as blending, so preparation refuses it. +fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool { + let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern")); + let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs)); + for op in ops { + if op.is("gs") && state_blends(op.name(0).and_then(|n| states.get(n))) { + return true; + } + let o = if op.is("Do") { + op.name(0).and_then(|n| xobjects.get(n)) + } else if op.is("scn") || op.is("SCN") { + op.operands.len().checked_sub(1).and_then(|i| op.name(i)).and_then(|n| patterns.get(n)) + } else { + continue; + }; + let Some(o) = o else { continue }; + let resolved = doc.resolve(o); + let nested = match &*resolved { + Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st, + Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st, + // A shading pattern. + Object::Stream(st) if !op.is("Do") => { + if state_blends(st.dict.get(b"ExtGState")) { + return true; + } + continue; + } + Object::Dict(d) if !op.is("Do") => { + if state_blends(d.get(b"ExtGState")) { + return true; + } + continue; + } + _ => continue, + }; + let r = o.as_ref(); + if let Some(known) = r.and_then(|r| walk.known.get(&r)) { + if *known { + return true; + } + continue; + } + if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS { + return true; + } + walk.reads += 1; + let Ok(data) = walk.budget.decode_strict(nested) else { return true }; + let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let has_own = own_resources.is_some(); + let own = own_resources.unwrap_or_else(|| resources.clone()); + let inner = parse(&data); + if inner.skipped > 0 { + return true; + } + if let Some(r) = r { + walk.path.push(r); + } + let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk); + if let Some(r) = r { + walk.path.retain(|x| *x != r); + if has_own { + walk.known.insert(r, blends); + } + } + if blends { + return true; + } + } + false +} + +/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency +/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses. +pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option { + let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?; + group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone()) +} + +/// Whether a group blending in `cs` composites as the page does: in the page group's own space +/// when it names one, else in DeviceRGB, the space viewers composite a page in on screen. +fn same_blend_space(doc: &Document, cs: &Object, page_space: Option<&Object>) -> bool { + let cs = doc.resolve(cs); + match page_space { + Some(page) => *cs == *page, + None => cs.as_name() == Some(b"DeviceRGB"), + } +} + +/// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may +/// be one, ISO 32000-2 §7.3.10). +fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool { + d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want)) +} + +/// `d`'s integer `key`, directly or through an indirect object. +fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| doc.resolve(v).as_int()) +} + +/// Whether an ExtGState blends with something other than Normal. +fn blends(doc: &Document, gs: &Dict) -> bool { + let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false }; + let first = match &*bm { + Object::Array(a) => a.first().map(|x| doc.resolve(x)), + _ => Some(bm.clone()), + }; + !first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible"))) +} + +/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's +/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs` +/// changes only the parameters it sets. +pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool { + let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + // Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal. + let mut stack: Vec<[bool; 4]> = Vec::new(); + let mut now = [false; 4]; + for op in ops.iter().take(at) { + match op.op.as_slice() { + b"q" => stack.push(now), + b"Q" => now = stack.pop().unwrap_or([false; 4]), + b"gs" => { + let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else { + continue; + }; + let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0); + if let Some(v) = alpha(b"ca") { + now[0] = v; + } + if let Some(v) = alpha(b"CA") { + now[1] = v; + } + if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) { + now[2] = m.as_name() != Some(b"None"); + } + if gs.get(b"BM").is_some() { + now[3] = blends(doc, &gs); + } + } + _ => {} + } + } + now.iter().any(|v| *v) +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 83046bfbf7..98de481444 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -2,9 +2,10 @@ use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, - RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, - no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, - source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, + RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, + needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, + source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, + union, write_type3_glyphs, }; #[path = "text_regions/grouping.rs"] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7a9204a8c0..5d8075785e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -2,9 +2,9 @@ use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, - RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, - no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, - splice, type3_faces, type3_glyph, write_type3_glyphs, + RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, + needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, + source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs, }; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. @@ -104,9 +104,16 @@ impl FontResolver<'_> { } } +struct LegacyFace { + glyphs: Option, + metrics: Metrics, +} + struct PreparedCharacters { values: Vec, substituted: Option, + outlined_thai: std::collections::HashSet, + legacy_thai: HashMap, } struct CharacterPreparation<'a> { @@ -125,12 +132,73 @@ struct CharacterPreparation<'a> { impl CharacterPreparation<'_> { fn prepare(self) -> Result { let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let thai_choice = builtin_thai_choice(fonts); + let mut outlined_thai = std::collections::HashSet::new(); + let mut legacy_thai = HashMap::new(); let mut chars = Vec::new(); let mut substituted = None; let mut fallback = FallbackBuilder::new(max_glyphs); let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; for (range, index) in spans { let source = lines.get(index).unwrap_or(first); + let span = draft.get(range.clone()).unwrap_or_default(); + // Keep the exact source face. A different Thai face requires an explicit choice. + // Interface fallback fonts never enter a PDF. + let selected_resource = overrides.get(&index).and_then(|r| r.resource.as_deref()).or(style.resource.as_deref()); + let use_thai_outlines = if selected_resource == Some(thai_choice.as_str()) { + let face = pdfcraft_fonts::document_thai_font() + .ok_or_else(|| EditError::Invalid("the bundled Thai font is unavailable in this build; load a complete Thai font".into()))?; + if !pdfcraft_fonts::embedding::covers(face.bytes, span) { + return Err(EditError::Invalid(format!( + "{} cannot show every character in this selection; choose a font that covers the text", + face.family + ))); + } + substituted = Some(face.family.to_owned()); + true + } else { + selected_resource.is_none() && matching_thai_outline(resolver.doc, resolver.fonts, source, span) + }; + if use_thai_outlines { + substituted = pdfcraft_fonts::document_thai_font().map(|f| f.family.to_owned()); + } + let existing_thai = if !use_thai_outlines { + let chosen = match selected_resource { + None => Some(source.clone()), + Some(resource) => resolver.fonts.get(resource.as_bytes()).and_then(|f| resolver.doc.dict(f)).map(|font| { + let mut chosen = source.clone(); + chosen.font = resource.to_owned(); + chosen.base_font = Metrics::from_dict(resolver.doc, &font).base_font; + chosen + }), + }; + match chosen { + Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut legacy_thai)?, + None => None, + } + } else { + None + }; + let outline_name = if use_thai_outlines { + // A draft-only name prevents this choice from affecting another style span that + // explicitly selects the original resource. It is never saved into the PDF. + let mut name = format!("PCOutlineThai{index}"); + while resolver.metrics.contains_key(&name) { + name.push('_'); + } + if let Some(m) = resolver + .metrics + .get(&source.font) + .or_else(|| source.origin.state.font.as_ref().and_then(|(name, _)| resolver.metrics.get(name))) + .cloned() + { + resolver.metrics.insert(name.clone(), m); + } + outlined_thai.insert(name.clone()); + Some(name) + } else { + None + }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { if ch == '\n' { @@ -144,7 +212,11 @@ impl CharacterPreparation<'_> { } let text = ch.to_string(); let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); - let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + let name = if let Some(name) = &outline_name { + name.clone() + } else if let Some(name) = &existing_thai { + name.clone() + } else if let Some(name) = resolver.encodable_font(source, &text, requested)? { name } else if needs_type3(&text) { fallback.prepare(source, ch, &text, chars.len())? @@ -173,7 +245,7 @@ impl CharacterPreparation<'_> { } substituted = Some(written.family.to_owned()); } - Ok(PreparedCharacters { values: chars, substituted }) + Ok(PreparedCharacters { values: chars, substituted, outlined_thai, legacy_thai }) } } @@ -233,7 +305,16 @@ struct StyledLines<'a> { type StyledChar = (char, usize, String); type StyledRows = Vec>; type RowRanges = Vec>; -type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; +enum ShapedFont { + Existing(Vec<(String, Vec)>), + Program { bytes: Vec, family: String }, + ThaiOutlines, +} +struct ShapedText { + run: pdfcraft_fonts::source_shaping::ShapedRun, + font: ShapedFont, +} +type ShapedRunCache = HashMap<(usize, usize), ShapedText>; struct ShapedRows { runs: ShapedRunCache, @@ -243,6 +324,8 @@ struct ShapedRows { fn prepare_shaped_rows( doc: &Document, fonts: &Dict, + outlined_thai: &std::collections::HashSet, + legacy_thai: &HashMap, rows: &[Vec], kept: &[bool], advance: impl Fn(&StyledChar) -> f64, @@ -259,8 +342,8 @@ fn prepare_shaped_rows( let segment = row.get(at..end).unwrap_or_default(); advances.insert((row_index, at), segment.iter().map(&advance).sum::()); let text: String = segment.iter().map(|character| character.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { - runs.insert((row_index, at), (bytes, shaped, family)); + if let Some(shaped) = shape_source(doc, fonts, outlined_thai, legacy_thai, &item.2, &text)? { + runs.insert((row_index, at), shaped); } at = end; } @@ -268,16 +351,138 @@ fn prepare_shaped_rows( Ok(ShapedRows { runs, advances }) } -fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { +type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec)>>; + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result { let mut resources = HashMap::new(); - for (key, (bytes, shaped, family)) in runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - resources.insert(*key, resource); + for (key, shaped) in runs { + let glyphs = match &shaped.font { + ShapedFont::Existing(codes) => codes.clone(), + ShapedFont::ThaiOutlines => crate::added::thai_glyph_resources(doc, fonts, &shaped.run)?, + ShapedFont::Program { bytes, family } => { + let mappings: Vec<_> = shaped.run.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + (0..shaped.run.glyphs.len()) + .map(|i| { + let code = u16::try_from(i.saturating_add(1)).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + Ok((resource.clone(), code.to_be_bytes().to_vec())) + }) + .collect::, EditError>>()? + } + }; + resources.insert(*key, glyphs); } Ok(resources) } +/// A positioned glyph run, kept in one text object across Type 3 font shards. +struct ShapedRunDrawing<'a> { + doc: &'a Document, + fonts: &'a Dict, + shaped: &'a pdfcraft_fonts::source_shaping::ShapedRun, + resources: &'a [(String, Vec)], + text: &'a str, + matrix: [f64; 6], + size: f64, + thai: bool, +} + +impl ShapedRunDrawing<'_> { + /// Draw and return the run's advance in text space, including cluster-safe spacing. + fn draw(&self, block_ops: &mut Vec, state: &mut TextState) -> Result { + let Self { doc, fonts, shaped, resources, text, matrix: tm, size, thai } = *self; + let n = pdfcraft_content::num; + let (resource, _) = self.resources.first().ok_or_else(|| EditError::Invalid("shaped run has no glyphs".into()))?; + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(text))); + block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + state.font = Some((resource.clone(), size)); + // One text object for the run: each glyph is placed where shaping put it by a TJ + // adjustment from where the glyph before it ends (its advance in the embedded + // font's widths), and raised by the text rise for a mark set above or below + // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; + // ToUnicode and ActualText keep the logical text. + let per_unit = 1000.0 / size; + if !per_unit.is_finite() { + return Err(EditError::Invalid("the shaped text has no size".into())); + } + let mut shown_fonts = HashMap::new(); + for (name, _) in resources { + if !shown_fonts.contains_key(name) { + let font = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + shown_fonts.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + } + // Keep the original text settings. TJ compensates for the automatic PDF advance + // after each glyph, while shaping keeps marks attached to their cluster. + let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + // `pen`: where the next glyph would start, in thousandths of the font size. + let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); + let mut shown_glyphs: Vec = Vec::new(); + let mut cluster_spacing = 0.0; + let mut current_resource = resource.as_str(); + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let (name, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?; + let shown = shown_fonts.get(name).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + if name != current_resource { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + block_ops.push(Op::new("Tf", vec![Object::name(name), n(size)])); + current_resource = name; + } + if thai && !glyph.text.is_empty() { + // Apply spacing only after a whole cluster, keeping marks on their base. + extra += cluster_spacing; + cluster_spacing = char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + let target = (glyph.position[0] * size + extra) * per_unit; + let glyph_rise = rise + glyph.position[1] * size; + if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + if (glyph_rise - raised).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); + raised = glyph_rise; + } + } + if (target - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - target)); + } + shown_glyphs.push(Object::String(PdfString::literal(code.clone()))); + let cid = code.iter().fold(0_u32, |value, byte| value.saturating_mul(256).saturating_add(u32::from(*byte))); + pen = target + + if code.is_empty() { + 0.0 + } else { + shown.width(cid) * 1000.0 + (char_spacing + if shown.is_space(cid, code.len()) { word_spacing } else { 0.0 }) * per_unit + }; + if !thai { + extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + } + let end = (shaped.advance * size + extra + cluster_spacing) * per_unit; + if (end - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - end)); + } + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); + } + if (raised - rise).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(rise)])); + } + block_ops.push(Op::new("ET", vec![])); + (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); + extra += cluster_spacing; + block_ops.push(Op::new("EMC", vec![])); + Ok(shaped.advance * size + extra) + } +} + fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { let text: String = row.iter().map(|character| character.0).collect(); @@ -578,19 +783,220 @@ fn apply_range_styles<'a>( StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } } +/// Select one existing subset of the exact source face for a Thai span. Never replace its +/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. +fn legacy_thai_font( + doc: &Document, + fonts: &Dict, + source: &TextLine, + span: &str, + cache: &mut HashMap, +) -> Result, EditError> { + if !source.base_font.rsplit('+').next().unwrap_or(&source.base_font).starts_with("THSarabunPSK") + || !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + { + return Ok(None); + } + let family = pdfcraft_fonts::embedding::face_key(&source.base_font); + let mut candidates = vec![source.font.clone()]; + candidates.extend(fonts.iter().filter_map(|(name, font)| { + let font = doc.dict(font)?; + (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) + })); + for name in candidates.into_iter().take(128) { + if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(name) { + let Some(font) = fonts.get(entry.key().as_bytes()).and_then(|f| doc.dict(f)) else { continue }; + let metrics = Metrics::from_dict(doc, &font); + let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics)).ok().flatten(); + entry.insert(LegacyFace { glyphs, metrics }); + } + } + for row in span.split(['\n', '\r']).filter(|row| !row.is_empty()) { + shape_existing_thai(cache, &source.font, row)?; + } + Ok(Some(source.font.clone())) +} + +/// A visual row may use several subsets of the same exact face (often a separate subset for +/// punctuation). Shape Thai clusters intact, and retain each glyph's original PDF font/code. +fn shape_existing_thai(cache: &HashMap, primary: &str, text: &str) -> Result { + if text.len() > 262_144 { + return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into())); + } + let first = cache.get(primary).ok_or_else(|| EditError::Invalid("the Thai source face is missing".into()))?; + let family = pdfcraft_fonts::embedding::face_key(&first.metrics.base_font); + let mut candidates = vec![(primary, first)]; + candidates.extend( + cache + .iter() + .filter(|(name, face)| name.as_str() != primary && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family) + .map(|(name, face)| (name.as_str(), face)), + ); + // Stable source-first choice, then resource name; never depend on hash iteration order. + if let Some(tail) = candidates.get_mut(1..) { + tail.sort_by_key(|(name, _)| *name); + } + let thai = |c: char| matches!(u32::from(c), 0x0e01..=0x0e5b | 0xf700..=0xf71a); + let mut run = pdfcraft_fonts::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 }; + let mut resources = Vec::new(); + let mut chars = text.char_indices().peekable(); + while let Some((at, ch)) = chars.next() { + if thai(ch) { + let mut end = at + ch.len_utf8(); + while let Some(&(next, c)) = chars.peek() { + if !thai(c) { + break; + } + chars.next(); + end = next + c.len_utf8(); + } + let segment = text.get(at..end).ok_or_else(|| EditError::Invalid("invalid Thai character boundary".into()))?; + let mut selected = None; + let mut why = String::new(); + for (name, face) in &candidates { + if let Some(font) = &face.glyphs { + match font.shape(segment) { + Ok(shaped) => { + selected = Some((*name, shaped)); + break; + } + Err(e) => { + if why.is_empty() { + why = e; + } + } + } + } + } + let (name, shaped) = selected.ok_or_else(|| { + EditError::Invalid(format!( + "{} cannot keep this edit in the original face: {}; load the complete original font or explicitly choose a replacement", + first.metrics.base_font, + if why.is_empty() { "its source glyph program is incomplete" } else { &why } + )) + })?; + for (mut glyph, code) in shaped.shaped.glyphs.into_iter().zip(shaped.codes) { + glyph.position[0] += run.advance; + resources.push((name.to_owned(), code)); + run.glyphs.push(glyph); + } + run.advance += shaped.shaped.advance; + } else { + let logical = ch.to_string(); + let found = candidates.iter().find_map(|(name, face)| { + let code = face.metrics.encode(&logical)?; + let cid = face.metrics.codes(&code).first()?.0; + Some((*name, code, face.metrics.width(cid))) + }); + let (name, code, width) = if let Some(found) = found { + found + } else if ch == ' ' { + // The source subset may omit spaces. Use a gap measured from a matching + // document subset, then the face's existing text-space fallback. + let width = candidates + .iter() + .find_map(|(_, f)| { + let code = f.metrics.encode(" ")?; + Some(f.metrics.width(f.metrics.codes(&code).first()?.0)) + }) + .unwrap_or(0.25); + (primary, Vec::new(), width) + } else { + return Err(EditError::Invalid(format!( + "{} has no source glyph for {ch} (U+{:04X}); load the complete original face or explicitly select a replacement", + first.metrics.base_font, + u32::from(ch) + ))); + }; + run.glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { id: Default::default(), position: [run.advance, 0.0], text: logical }); + resources.push((name.to_owned(), code)); + run.advance += width; + } + if run.glyphs.len() > 65_534 || !run.advance.is_finite() { + return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into())); + } + } + Ok(ShapedText { run, font: ShapedFont::Existing(resources) }) +} + +/// Whether the approved Thai face can replace this source, whose embedded subset cannot +/// shape the replacement. A usable complete source program always takes precedence. +fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool { + let Some(face) = pdfcraft_fonts::document_thai_font() else { return false }; + if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + || !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face)) + || !pdfcraft_fonts::embedding::covers(face.bytes, span) + { + return false; + } + // A registered complete matching program also completes the source subset. + // Do not replace it with bundled outlines merely because the original resource is a subset. + let family = pdfcraft_fonts::embedding::face_key(&source.base_font); + !fonts.iter().filter_map(|(_, font)| doc.dict(font)).any(|font| { + pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family + && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { + pdfcraft_fonts::embedding::validate(&bytes).is_ok() + && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) + && pdfcraft_fonts::embedding::covers(&bytes, span) + && span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + }) + }) +} + +/// Use the same Thai script selection and mark/cluster positioning as Add Text, including +/// rows beginning with Latin or digits (guessing from their first character would select Latin). +fn shape_thai_row(text: &str) -> Result { + if text.len() > 262_144 || text.contains(['\n', '\r']) { + return Err(EditError::Invalid("shape one bounded text row at a time".into())); + } + let clusters = + pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?; + let mut glyphs = Vec::new(); + let mut advance = 0.0; + for cluster in clusters { + for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() { + if glyphs.len() >= 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { + id: id.into(), + position: [advance + x, y], + text: if i == 0 { cluster.text.clone() } else { String::new() }, + }); + } + advance += cluster.advance; + } + if !advance.is_finite() { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance }) +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, fonts: &Dict, + outlined_thai: &std::collections::HashSet, + legacy_thai: &HashMap, name: &str, text: &str, -) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { +) -> Result, EditError> { + if outlined_thai.contains(name) { + let shaped = shape_thai_row(text)?; + return Ok(Some(ShapedText { run: shaped, font: ShapedFont::ThaiOutlines })); + } if !pdfcraft_fonts::source_shaping::required(text) { return Ok(None); } let source = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; - let family = Metrics::from_dict(doc, &source).base_font; + let metrics = Metrics::from_dict(doc, &source); + if metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font).starts_with("THSarabunPSK") + && text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + { + return shape_existing_thai(legacy_thai, name, text).map(Some); + } + let family = metrics.base_font; let mut candidates = vec![source]; candidates.extend( fonts @@ -603,10 +1009,17 @@ fn shape_source( && pdfcraft_fonts::embedding::covers(&bytes, text) && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) { - return Ok(Some((bytes, shaped, family))); + return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } })); } } - Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) + let fallback = pdfcraft_fonts::document_thai_font() + .filter(|_| text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))) + .map(|face| format!("; or select {} in the document-font menu to replace it", face.family)) + .unwrap_or_default(); + Err(EditError::Invalid(format!( + "complex text in {} needs its complete source TrueType/OpenType font; use Load font to keep it{fallback}", + family.rsplit('+').next().unwrap_or(&family) + ))) } /// For each output row (a range of the draft's characters), whether it is exactly the source row @@ -889,6 +1302,8 @@ pub(crate) fn rewrite_styled_block( max_glyphs, } .prepare()?; + let outlined_thai = prepared.outlined_thai; + let legacy_thai = prepared.legacy_thai; let chars = prepared.values; let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { @@ -939,7 +1354,8 @@ pub(crate) fn rewrite_styled_block( } let segment = items.get(at..end).unwrap_or_default(); let string: String = segment.iter().map(|v| v.0).collect(); - if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + if let Some(shaped) = shape_source(doc, &fonts, &outlined_thai, &legacy_thai, &item.2, &string)? { + let shaped = &shaped.run; let source = lines.get(item.1).unwrap_or(first); let scale = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); @@ -990,7 +1406,7 @@ pub(crate) fn rewrite_styled_block( let at = if split { start } else { o.bt_op }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; - let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let shaped = prepare_shaped_rows(doc, &fonts, &outlined_thai, &legacy_thai, &rows, &kept, advance)?; let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; @@ -1100,67 +1516,19 @@ pub(crate) fn rewrite_styled_block( // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); block_ops.extend(state.paint_ops(drawn_from, run_ctm)); - if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { - let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - let mut properties = Dict::new(); - properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); - block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); - state.font = Some((resource.clone(), size_of(source, item.1))); - // One text object for the run: each glyph is placed where shaping put it by a TJ - // adjustment from where the glyph before it ends (its advance in the embedded - // font's widths), and raised by the text rise for a mark set above or below - // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; - // ToUnicode and ActualText keep the logical text. - let size = size_of(source, item.1); - let per_unit = 1000.0 / size; - if !per_unit.is_finite() { - return Err(EditError::Invalid("the shaped text has no size".into())); - } - let shown = fonts - .get(resource.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .map(|d| Metrics::from_dict(doc, &d)) - .ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - // Spacing is part of each glyph's position, so the run itself has none. - let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); - state.char_spacing = 0.0; - state.word_spacing = 0.0; - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // `pen`: where the next glyph would start, in thousandths of the font size. - let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); - let mut shown_glyphs: Vec = Vec::new(); - for (i, glyph) in shaped.glyphs.iter().enumerate() { - let target = (glyph.position[0] * size + extra) * per_unit; - let glyph_rise = rise + glyph.position[1] * size; - if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); - } - if (glyph_rise - raised).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); - raised = glyph_rise; - } - } - if (target - pen).abs() > 1e-6 { - shown_glyphs.push(n(pen - target)); - } - let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; - shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec()))); - pen = target + shown.width(u32::from(code)) * 1000.0; - extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; - } - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); - } - if (raised - rise).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(rise)])); - } - block_ops.push(Op::new("ET", vec![])); - (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); - block_ops.push(Op::new("EMC", vec![])); - segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; + if let Some(shaped) = shaped_runs.get(&(row_index, pos)) { + let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let drawing = ShapedRunDrawing { + doc, + fonts: &fonts, + shaped: &shaped.run, + resources, + text: &string, + matrix: tm, + size: size_of(source, item.1), + thai: string.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)), + }; + segment_width = drawing.draw(&mut block_ops, &mut state)? * state.scale * source.origin.k; } else { block_ops.push(Op::new("BT", vec![])); block_ops.extend(state.ops()); diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 279dc8d563..f3b0d75799 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -255,6 +255,20 @@ impl Document { pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) } + /// Whether this page has grouped source text that can be prepared without changing its appearance. + /// Used once when entering the existing-text tool, before queuing an undoable preparation. + pub fn can_prepare_text(&self, page: usize) -> bool { + self.text_preparation(page).is_ok_and(|p| p.expanded > 0) + } + + /// Preview preparation, including skipped groups and character-map repairs, without editing. + pub fn text_preparation(&self, page: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document is not editable")?; + crate::guard(|| pdfcraft_edit::text::forms::prepare(&mut editor.cos.clone(), page)) + .map_err(|e| format!("grouped text could not be prepared safely ({e})"))? + .map_err(|e| e.to_string()) + } + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). pub fn text_blocks(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 1bcf24108f..f1a99a2387 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -466,6 +466,10 @@ pub enum Edit { /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. style: pdfcraft_edit::BlockStyle, }, + /// Expose supported grouped source text on one page for direct editing. + PrepareText { + page: usize, + }, /// An explicit open-licensed full font program, completing subsets or selected by a user. RegisterTextFont { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 615ac0fea5..b66ef713f2 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -111,6 +111,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete document JavaScript", "Edit document JavaScript", "Edit text", + "Prepare grouped text", "Load document font", "Move object", "Move objects", @@ -269,6 +270,7 @@ impl Edit { Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::PrepareText { .. } => Label::of("Prepare grouped text"), Edit::RegisterTextFont { .. } => Label::of("Load document font"), Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), Edit::EditPageImage { change, .. } => Label::of(match change { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 4719de302f..6368e9097c 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -148,6 +148,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::PrepareText { .. } | Edit::RegisterTextFont { .. } | Edit::EditPageImage { .. } | Edit::Flatten { .. } @@ -726,6 +727,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } + Edit::PrepareText { page } => { + let result = pdfcraft_edit::text::forms::prepare(doc, *page)?; + if result.expanded == 0 { + return Err(EditError::Invalid( + result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()), + )); + } + } Edit::RegisterTextFont { page, bytes } => { pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ce51df5e61..4997f64efe 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -81,6 +81,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::MoveObjects { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::PrepareText { .. } | Edit::EditPageImage { .. } | Edit::EnhanceScan { .. } | Edit::AddText { .. } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9004d88205..7959d2ae3f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -617,6 +617,8 @@ pub struct DocView { /// Edit text: the lines per page (with the document generation they were read at), and the /// line being edited. pub(crate) edit_lines: HashMap)>, + /// Pages checked for grouped source text during this Edit text activation. + pub(crate) prepared_text_pages: std::collections::HashSet, pub line_editor: Option, /// The paragraph editor whose edit was just queued. It opens again with its typing if the /// edit fails, as a refused form field does. @@ -1006,6 +1008,7 @@ impl DocView { organize_range: Default::default(), pending_edit: None, edit_lines: HashMap::new(), + prepared_text_pages: Default::default(), line_editor: None, applied_editor: None, pending_text_open: None, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e1c9719668..34e1362202 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -66,6 +66,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + if tool != QuickTool::EditText { + view.prepared_text_pages.clear(); + } else if can_modify && view.pending_edit.is_none() && view.line_editor.is_none() && view.prepared_text_pages.insert(i) && doc.can_prepare_text(i) + { + view.pending_edit = Some(pdfcraft_engine::Edit::PrepareText { page: i }); + ui.ctx().request_repaint(); + } let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 1946519203..19331230a7 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -68,7 +68,9 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 { /// One TextEdit lays out all source runs; its caret and selection span the whole region. pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap { - let programs = doc.text_editor_fonts(page); + // Bundled replacement selectors are menu choices, never source resource names. Installing + // an unused choice would make even a standard-font editor wait a frame for its font atlas. + let programs: Vec<_> = doc.text_editor_fonts(page).into_iter().filter(|font| !font.choice_only).collect(); let mut definitions = ctx.fonts(|f| f.definitions().clone()); // Only the current editor's document fonts live in the atlas. Never accumulate the // programs of previously opened files or add them to the interface's default families. diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 426058f7b7..a8966ff207 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -1,5 +1,16 @@ # Editing existing text and source subsets +Entering **Edit text and images** prepares supported grouped Form XObjects on the visible +page in an undoable **Prepare grouped text** step. The group's original operators, clip, +placement and resources become page content; shared forms on other pages stay unchanged. +Headless callers use `text_prepare`, then list `text_paragraphs` and call `text_edit`. +This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box. +Preparation refuses knockout/blended groups, incompatible colour spaces, tagged forms, +recursive forms, unfinished paths and unreadable content. Decoding is capped at 16 MiB, +64 placements and eight expansion levels per page. Unsupported groups remain intact. +A narrowly fingerprinted legacy THSarabunPSK exporter map can also be repaired in copied page +fonts; the repair log records both preparation and character-map recovery. + Click a text region in **Edit a PDF → Edit text and images**. One editor contains its source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; source font programs, colour, size and baseline anchors stay intact. A textless preview @@ -44,6 +55,30 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +**Keep source fonts** retains the existing Thai family, weight and point size. For legacy +THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's +Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the +saved text refers to the original PDF font resources. Several subsets of the same exact +face can supply Thai and punctuation glyphs; Regular and Bold stay separate. Source font +programs are never modified or replaced. Text settings, including character/word spacing, +horizontal scale, rise, color and paint mode, remain in the PDF text state. + +A source subset that lacks a needed contextual glyph produces an error asking for the +complete original face or an explicit replacement. Cross-family replacement requires an +explicit choice. The document-font menu offers **Noto Sans Thai**: pass its `text_fonts` +resource (normally `builtin:thai`) as `text_edit.font_resource` or a range's `font_resource`. This replacement +requires the optional craft-fonts Thai input and currently uses Regular. A matching Noto +subset can still be completed by that same bundled family. + +The bundled variable Thai face is written as bounded Type 3 glyph outlines, with shaped +vowel/tone positions and logical Unicode text, rather than embedded as a static font. +Saved paragraphs can be reopened and edited again without installing a font. Modern Thai +shaping uses the source's GPOS mark positioning; GSUB substitution is optional, as specified by +[the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai). +Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS. +Mixed contextual style runs and dictionary word segmentation remain limited. No font binary +or release pin changes are needed. + When mixed-style text needs outline fallback, characters using the same actual fallback face share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same font and source style are written as one run. Latin characters that the source font can encode diff --git a/docs/roadmap.md b/docs/roadmap.md index 736d53bb7a..28afeea246 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation; estimates unchanged) + > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) @@ -239,7 +241,7 @@ total matches the parity estimate. | 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; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
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: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
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: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | 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; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
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: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. 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; XFDF values retain text across XML comments and processing instructions.
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. | @@ -312,6 +314,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. | | 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10b040e09f..54aa9561b5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1894,9 +1894,9 @@ tier = "P0" milestone = "M7" status = "partial" commands = ["edit.edit_text"] -tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" +tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"] +notes = "Supported grouped Forms are prepared on entering Edit text, or via text_prepare, so their existing source glyphs can be edited directly. Unsafe groups remain intact. Narrow legacy THSarabunPSK character-map recovery is recorded. Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets, preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow"