From 7634ac806457419c5a870da5f7a9ef9da709396e Mon Sep 17 00:00:00 2001 From: xullul Date: Sat, 7 Nov 2026 15:59:38 +0700 Subject: [PATCH] M7: address review of grouped Thai text preparation Add: - Report text grouped more than eight levels deep instead of leaving it silently grouped, and refuse forms whose resource-naming operators carry surplus operands, which would otherwise lose nested artwork when inlined. - Return the applied preparation's own counts from Session::prepare_text, so text_prepare no longer reports a separate preview's. - Shape a THSarabunPSK program that has its own Thai mark positioning by that layout, in its own codes. A span the subsets can't show in place is shaped whole, Latin rows included, by a complete program of the same face and style, such as a CFF face from Load font. Change: - Expand only forms that show text, directly or through nested forms; image- and vector-only forms stay grouped and uncounted. Match a Do's raw resource name bytes, and refuse RGB-blended groups on pages naming no blending space. - Check the recovered THSarabunPSK map and the Windows PUA alternates glyph by glyph against the subset's advances, leave dropped glyphs unmapped, and insert the repair after the last endcmap. - Shape a Thai run split across same-face subsets cluster by cluster and skip subset glyphs that are empty. Take the legacy path only for programs in the legacy glyph order, needing only the glyphs the new text uses. - Require GPOS mark/mkmk in the script and language system HarfRust selects. - Match same-face candidates by weight and slant from the name, descriptor weight or a non-zero ItalicAngle; the Italic flag alone is ignored, as exporters set it on upright faces. Never complete bold or italic text with the Regular bundled Thai face. - Name the missing character, or the unavailable builtin:thai choice, in errors. Chores: - Document text_prepare's fields, builtin:thai, the app's refusal reporting limits and the new routing; extend the text-edit parity evidence. Tests that need craft-fonts now say they skipped instead of returning silently. Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit 465, engine 482 (+28 integration) and automation 50 + 352 passed; editor flows 248 passed. Formatting, strict Clippy, layers, assets and parity passed; the wasm check was not run locally. Each new regression failed with its fix reverted. Real-PDF edits on Royal Gazette 26288 kept their source fonts. --- ROADMAP.md | 2 +- crates/automation/README.md | 26 +- crates/automation/src/dispatch.rs | 7 +- crates/automation/src/documents.rs | 17 + crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 47 ++- crates/edit/src/added.rs | 2 + crates/edit/src/added/thai_tests.rs | 59 +++ crates/edit/src/tests.rs | 514 ++++++++++++++++++++++- crates/edit/src/text/forms.rs | 28 +- crates/edit/src/text/forms/expand.rs | 103 +++-- crates/edit/src/text_regions/styled.rs | 313 ++++++++++---- crates/engine/src/edit/run.rs | 5 + crates/engine/src/session/editing.rs | 17 +- crates/fonts/src/pdf.rs | 15 +- crates/fonts/src/pdf/thai.rs | 315 ++++++++++++-- crates/fonts/src/source_shaping.rs | 135 +++++- docs/editing-existing-text.md | 36 +- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 20 files changed, 1452 insertions(+), 199 deletions(-) create mode 100644 crates/edit/src/added/thai_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 988be90914..a9087c188e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -232,7 +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-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; 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 9c31754e0d..2f50b7394c 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -204,10 +204,28 @@ 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. +`text_fonts` also lists `builtin:thai` when the build includes the bundled Noto Sans Thai face +(the optional craft-fonts input). Passing it as `text_edit.font_resource` explicitly replaces a +paragraph or range with that face; the source font is never swapped for it silently. A build +without the face never lists it, and `text_edit` refuses it there. A page whose own fonts fill +the listing's 32 MiB preview budget can leave it out too; `text_edit` still accepts it then. + +### Preparing grouped text + +`text_prepare {doc, page}` 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 and map repairs in the repair log, and is one undoable step. List +`text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document +summary it returns what this edit did: + +| Field | Type | Meaning | +| --- | --- | --- | +| `prepared_groups` | integer | Form placements drawn in place, so their text is now the page's own. | +| `repaired_fonts` | integer | Page fonts whose damaged legacy THSarabunPSK Thai map was repaired on a page-owned copy. | +| `refused_groups` | array of strings | One `name: reason` per group left as it was: unsafe compositing, tags, malformed content, or text grouped more than eight levels deep. | + +It fails, changing nothing, when no group on the page can be prepared (the error gives the first +reason) or the page is out of range. ### Superscript and subscript normalization diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 0d8c6e2333..ba2f7a2c0c 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -482,12 +482,7 @@ impl Automation { } "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 + self.prepare_text(&a, page)? } "text_paragraphs" => { let page = self.page(&a)?; diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index f8c7988bbc..86fce61f33 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -444,6 +444,23 @@ impl Automation { pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result { let id = self.doc(a)?.id; self.session.apply(id, edit).map_err(failed)?; + self.applied(a) + } + + /// `text_prepare`: make a page's grouped text editable, with what that edit itself prepared, + /// repaired and left grouped. + pub(crate) fn prepare_text(&mut self, a: &Args, page: usize) -> Result { + let id = self.doc(a)?.id; + let prepared = self.session.prepare_text(id, page).map_err(failed)?; + let mut out = self.applied(a)?; + out["prepared_groups"] = json!(prepared.expanded); + out["repaired_fonts"] = json!(prepared.repaired_fonts); + out["refused_groups"] = json!(prepared.refused); + Ok(out) + } + + /// The document's summary after an edit, with what it stored as typed but drew with `?`. + fn applied(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let mut out = summary(doc); // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c58d803284..7bdefac89b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1581,10 +1581,10 @@ 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 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.") + 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. builtin:thai explicitly replaces a paragraph or range with the bundled Noto Sans Thai; a build without that face never lists it and text_edit refuses it there (a page whose fonts fill the listing budget may also omit it, but text_edit still accepts it). 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.") + 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 (placements made editable), repaired_fonts (damaged legacy Thai maps repaired) and refused_groups ('name: reason' for each group left as it was). Fails when nothing on the page can be prepared. 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.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 21c958b52e..493a34899e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11458,12 +11458,34 @@ fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() { 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()); + let Err(ToolError::Failed(m)) = + a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"})) + else { + panic!("Noto Sans Thai has no emoji") + }; + assert!(m.contains("Noto Sans Thai") && m.contains("U+1F600"), "the error names the font and the character: {m}"); 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 { .. }))); } +/// `text_prepare` refuses what it can't act on, changing nothing: a page with no grouped text +/// (with the reason), and a page the document doesn't have. +#[test] +fn text_prepare_refuses_pages_without_grouped_text() { + 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 Err(ToolError::Failed(m)) = a.call("text_prepare", &json!({"doc":doc,"page":1})) else { panic!("nothing to prepare") }; + assert!(m.contains("no supported grouped text"), "{m}"); + let Err(ToolError::InvalidArgs(m)) = a.call("text_prepare", &json!({"doc":doc,"page":2})) else { panic!("no page 2") }; + assert!(m.contains("out of range"), "{m}"); + assert!(a.call("edit_undo", &json!({"doc":doc})).is_err(), "nothing was recorded to undo"); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["document"]["dirty"], false); +} + #[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}; @@ -11518,14 +11540,25 @@ fn grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_gly 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})); + let prepared = ok(&mut a, "text_prepare", json!({"doc":id,"page":1})); + assert_eq!( + [&prepared["prepared_groups"], &prepared["repaired_fonts"], &prepared["refused_groups"]], + [&json!(1), &json!(0), &json!([])], + "{prepared}" + ); 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; - } + // The bundled Thai face is an optional build input. Without it, `builtin:thai` is refused and + // the grouped edit, undo, redo and save checks run on Latin text in Helvetica instead. + let thai = json!({"doc":id,"page":1,"paragraph":1,"text":new,"font_resource":"builtin:thai"}); + let (new, args) = if pdfcraft_fonts::document_thai_font().is_some() { + (new, thai) + } else { + let Err(ToolError::Failed(m)) = a.call("text_edit", &thai) else { panic!("builtin:thai needs the bundled Thai face") }; + assert!(m.contains("builtin:thai"), "{m}"); + let latin = "Edited body"; + (latin, json!({"doc":id,"page":1,"paragraph":1,"text":latin,"font":"helvetica"})) + }; 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"); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 4abe4a02eb..43ca9de0bc 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -88,3 +88,5 @@ fn align_from(code: Option) -> Align { #[cfg(test)] mod arabic_tests; +#[cfg(test)] +mod thai_tests; diff --git a/crates/edit/src/added/thai_tests.rs b/crates/edit/src/added/thai_tests.rs new file mode 100644 index 0000000000..87d58a8e69 --- /dev/null +++ b/crates/edit/src/added/thai_tests.rs @@ -0,0 +1,59 @@ +//! Existing Thai text drawn from the bundled Thai outlines (`thai_glyph_resources`): one Type 3 +//! code per distinct glyph and cluster text, in fonts of `GLYPHS_PER_FONT` codes each. The +//! outline checks need the craft-fonts Thai face; the budget check runs without it. + +use pdfcraft_cos::{Dict, Document}; +use pdfcraft_fonts::source_shaping::{ShapedGlyph, ShapedRun}; + +use super::*; + +/// `count` copies of `glyph`, each with cluster text of its own (so each needs a code of its own). +fn distinct(glyph: &ShapedGlyph, count: usize) -> ShapedRun { + let glyphs = (0..count).map(|i| ShapedGlyph { position: [i as f64, 0.0], text: format!("{i}"), ..glyph.clone() }).collect(); + ShapedRun { glyphs, advance: count as f64 } +} + +#[test] +fn thai_glyph_resources_refuse_a_row_past_the_glyph_budget() { + let glyph = ShapedGlyph { id: Default::default(), position: [0.0, 0.0], text: String::new() }; + let over = ShapedRun { glyphs: vec![glyph; 65_535], advance: 0.0 }; + let (mut doc, mut fonts) = (Document::new_empty(), Dict::new()); + assert!(thai_glyph_resources(&mut doc, &mut fonts, &over).is_err()); + assert!(fonts.is_empty(), "a refused row adds no font"); +} + +/// Codes fill a font up to `GLYPHS_PER_FONT`, the next distinct cluster starts another font at +/// code 1, and the same glyph with the same text again reuses its code. One glyph drawn for two +/// cluster texts takes two codes, since each code's ToUnicode gives its own text. +#[test] +fn thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary() { + let Some(face) = pdfcraft_fonts::document_thai_font() else { + eprintln!( + "skipping thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary: built without craft-fonts (set CRAFT_FONTS_DIR to run it)" + ); + return; + }; + let ko = pdfcraft_fonts::source_shaping::shape(face.bytes, "ก").unwrap().glyphs.remove(0); + for (clusters, fonts_used) in [(GLYPHS_PER_FONT - 1, 1), (GLYPHS_PER_FONT, 1), (GLYPHS_PER_FONT + 1, 2)] { + let mut shaped = distinct(&ko, clusters); + shaped.glyphs.push(shaped.glyphs[0].clone()); + let (mut doc, mut fonts) = (Document::new_empty(), Dict::new()); + let placed = thai_glyph_resources(&mut doc, &mut fonts, &shaped).unwrap(); + assert_eq!((fonts.len(), placed.len()), (fonts_used, clusters + 1), "{clusters} clusters"); + assert_eq!(placed.last(), placed.first(), "a repeated glyph and text reuses its code"); + let codes: std::collections::HashSet<_> = placed.iter().collect(); + assert_eq!(codes.len(), clusters, "every distinct cluster has a code of its own"); + assert_eq!(placed[GLYPHS_PER_FONT - 2].1, [(GLYPHS_PER_FONT - 1) as u8]); + if clusters > GLYPHS_PER_FONT { + assert_eq!(placed[GLYPHS_PER_FONT - 1].1, [GLYPHS_PER_FONT as u8]); + assert_eq!(placed[GLYPHS_PER_FONT].1, [1], "the next font starts at code 1"); + assert_ne!(placed[GLYPHS_PER_FONT].0, placed[0].0); + } + } + // At the budget: one cluster repeated 65 534 times needs one code. + let mut row = distinct(&ko, 1); + row.glyphs = vec![row.glyphs[0].clone(); 65_534]; + let (mut doc, mut fonts) = (Document::new_empty(), Dict::new()); + let placed = thai_glyph_resources(&mut doc, &mut fonts, &row).unwrap(); + assert_eq!((fonts.len(), placed.len()), (1, 65_534)); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d8f0ca16ee..e425d00328 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10753,7 +10753,13 @@ fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_fon #[test] fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() { - let Some(_) = pdfcraft_fonts::document_thai_font() else { return }; + // The matching full face is the optional craft-fonts input; CI builds with it. + if pdfcraft_fonts::document_thai_font().is_none() { + eprintln!( + "skipping existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font: built without craft-fonts (set CRAFT_FONTS_DIR to run it)" + ); + 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"); @@ -10781,6 +10787,35 @@ fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_fon assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next); } +/// The bundled Thai face is Regular: a subset of the same family that its descriptor marks bold +/// (a variable-font instance keeps the family name) is not completed with it silently. The +/// edit is refused, unchanged, until a replacement is chosen explicitly. +#[test] +fn a_bold_thai_subset_is_not_completed_with_the_regular_bundled_face() { + if pdfcraft_fonts::document_thai_font().is_none() { + eprintln!( + "skipping a_bold_thai_subset_is_not_completed_with_the_regular_bundled_face: built without craft-fonts (set CRAFT_FONTS_DIR to run it)" + ); + return; + } + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontWeight".to_vec(), Object::Int(700)); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + f.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + }) + .unwrap(); + assert!(text::text_lines(&doc, 0).unwrap()[0].bold, "the descriptor makes the source bold"); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let error = text::rewrite_block(&mut doc, 0, 0, Some("ผู้ใช้"), &text::BlockStyle::default()).expect_err("no silent Regular completion"); + assert!(!error.to_string().is_empty()); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); +} + #[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())]); @@ -10833,17 +10868,454 @@ fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_i 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()); + let [refused] = result.refused.as_slice() else { panic!("one refusal for {page_ops}, {form_ops}, {extra}: {:?}", result.refused) }; + assert!(refused.starts_with("Body: "), "the refusal names the group: {refused}"); assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); } } +/// A form whose content gives an operator a surplus operand (`0 /Inner Do`) is left grouped. The +/// renderer draws the last operand, while the inlined copy's resources are renamed at a fixed +/// position: the inner artwork would lose its resource and silently vanish. #[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. +fn grouped_text_with_surplus_resource_operands_is_left_grouped() { + let form = |body: &str| { + stream("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Inner 7 0 R >> >>", body) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Body 6 0 R >> >> /Contents 4 0 R >>" + .to_string(), + stream("", "/Body Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + form("BT /F1 14 Tf 40 500 Td (body) Tj ET 0 /Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << >>", "0 0 10 10 re f"), + ]; + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 0); + assert!(result.refused.iter().any(|r| r.starts_with("Body: ")), "{:?}", result.refused); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); + // The same form with well-formed operands is prepared, its inner artwork renamed with it. + let objs: Vec = + objs.iter().enumerate().map(|(i, o)| if i == 5 { form("BT /F1 14 Tf 40 500 Td (body) Tj ET /Inner Do") } else { o.clone() }).collect(); + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 1); + let page = &pdfcraft_model::pages(&doc)[0]; + let xobjects = page.dict.get(b"Resources").and_then(|r| doc.dict(r)).and_then(|r| r.get(b"XObject").and_then(|x| doc.dict(x))).unwrap(); + for op in pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.iter().filter(|op| op.is("Do")) { + assert!(xobjects.contains(op.last_name().unwrap()), "every drawn name resolves: {op:?}"); + } +} + +/// Text grouped deeper than preparation opens is reported, not passed over: of nine nested +/// forms, eight are opened and the ninth, which holds the text, is named in the refusals. +#[test] +fn grouped_text_nested_past_the_level_limit_is_reported() { + let forms: Vec<(String, String)> = (1..=9) + .map(|i| (format!("Body{i}"), if i < 9 { format!("/Body{} Do", i + 1) } else { "BT /F1 14 Tf 40 500 Td (deep) Tj ET".into() })) + .collect(); + let mut doc = page_with_forms("/Body1 Do", &forms); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 8); + assert_eq!(result.refused, ["Body9: its text is grouped more than 8 levels deep"]); + // Eight levels are still opened completely: the ninth, one level less deep, is prepared. + let mut doc = page_with_forms("/Body2 Do", &forms); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!((result.expanded, result.refused.len()), (8, 0)); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "deep")); +} + +/// A form that draws only an image (itself, or through a form it draws) is artwork: it is +/// neither expanded nor counted. Text reached through a nested form still is. +#[test] +fn grouped_image_artwork_is_not_prepared() { + let image = "q 10 0 0 10 0 0 cm /Im Do Q"; + for forms in [vec![("Logo", image)], vec![("Logo", "/Inner Do"), ("Inner", image)]] { + let forms: Vec<(String, String)> = forms.into_iter().map(|(n, b)| (n.to_string(), b.to_string())).collect(); + let mut doc = page_with_forms("/Logo Do", &forms); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!((result.expanded, result.refused.len()), (0, 0), "{forms:?}"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "{forms:?}"); + } + let forms = [("Logo".to_string(), "/Inner Do".to_string()), ("Inner".to_string(), "BT /F1 14 Tf 40 500 Td (inner) Tj ET".to_string())]; + let mut doc = page_with_forms("/Logo Do", &forms); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 2); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); +} + +/// The `Do` is matched to its resource by the name's bytes as written, which need not be UTF-8. +#[test] +fn grouped_text_under_a_non_utf8_resource_name_is_prepared() { + let mut doc = page_with_forms("/F#FF Do", &[("F#FF".into(), "BT /F1 14 Tf 40 500 Td (named) Tj ET".into())]); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!((result.expanded, result.refused.len()), (1, 0), "{:?}", result.refused); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "named")); +} + +/// A transparency group that blends in DeviceRGB composites as the page does only on a page +/// whose own group says DeviceRGB too. On a page that names no blending space the processor +/// chooses one (often CMYK in print), so inlining could change colours: that group is refused. +#[test] +fn grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb() { + for page_group in [false, true] { + let mut doc = page_with_forms("/Body Do", &[("Body".into(), "0 0 0 1 k BT /F1 14 Tf 40 500 Td (body) Tj ET".into())]); + let group = || build(&["<< /S /Transparency /CS /DeviceRGB >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + let Object::Stream(mut s) = doc.get(pdfcraft_cos::ObjRef::new(7, 0)).as_ref().clone() else { panic!("fixture") }; + s.dict.set(b"Group".to_vec(), Object::Dict(group())); + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(s)); + if page_group { + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| d.set(b"Group".to_vec(), Object::Dict(group()))).unwrap(); + } + let result = text::forms::prepare(&mut doc, 0).unwrap(); + if page_group { + assert_eq!((result.expanded, result.refused.len()), (1, 0), "{:?}", result.refused); + } else { + assert_eq!(result.expanded, 0); + assert!(result.refused.iter().any(|r| r.contains("colour space of its own")), "{:?}", result.refused); + } + } +} + +/// The damaged THSarabunPSK map is repaired with entries after the last `endcmap`: a stream with +/// a second block that repeats corrupt destinations must not override the corrective entries. +#[test] +fn grouped_legacy_thai_map_repair_follows_every_block_of_the_damaged_map() { + let damaged = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange \ + <00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \ + <0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \ + <00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \ + <0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> endbfrange endcmap\n\ + begincmap 2 beginbfchar <0103> <0036> <0107> <0040> endbfchar endcmap"; + // ฮา: codes 259 and 263, which the second block maps to "6@" again. + let mut doc = page_with_forms("/Body Do", &[("Body".into(), "BT /F1 14 Tf 40 500 Td <01030107> Tj ET".into())]); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), damaged); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!((result.expanded, result.repaired_fonts), (1, 1)); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.text == "ฮา"), "{:?}", lines.iter().map(|l| &l.text).collect::>()); +} + +/// A Thai run whose glyphs a subsetter split across two subsets of the face is kept in that +/// face: each cluster comes from a subset that has it. A cluster no subset has is refused with +/// the reason, and the document is unchanged. +#[test] +fn editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face() { + // F1 lacks ค (glyph 217), F2 lacks ข (215); "ขค" needs both. + let mut without_kho_khwai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khwai, 217); + let mut without_kho_khai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khai, 215); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let first = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &without_kho_khwai, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(first)); + let second = legacy_thai_font(&mut doc, "GHIJKL+THSarabunPSK", &without_kho_khai, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(second)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).expect("the face's subsets together have every glyph"); + assert_eq!(edited.substituted, None); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "กขค"); + let shown = shown_codes(&doc); + assert!(shown.contains(&("F1".into(), vec![0x00, 0xd6])) && shown.contains(&("F1".into(), vec![0x00, 0xd7])), "ก and ข from F1: {shown:?}"); + assert!(shown.contains(&("F2".into(), vec![0x00, 0xd9])), "ค from the subset that has it, F2: {shown:?}"); + assert_eq!(page_font_programs(&doc).iter().filter(|(_, program)| program.is_some()).count(), 2, "no font program is added"); + // ฆ (glyph 219) is in no subset of the face. + let mut neither = legacy_thai_program(); + drop_sfnt_glyph(&mut neither, 219); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let only = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &neither, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(only)); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let error = text::rewrite_block(&mut doc, 0, 0, Some("กฆ"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("load the complete original font"), "{error}"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); +} + +/// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is +/// shaped with its own layout like any source font, not by the legacy shaper (which would ask +/// for Windows PUA alternates such a face doesn't need). +#[test] +fn a_thai_subset_with_mark_positioning_is_shaped_with_its_own_layout() { 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| &r[..4] != b"cmap").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); + } + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("ปู่"), &style).expect("its own layout positions the marks"); + assert_eq!(edited.substituted, None); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "ปู่"); + assert!(shown_codes(&doc).iter().all(|(font, _)| font == "F1"), "drawn in the source resource's own codes: {:?}", shown_codes(&doc)); +} + +/// A legacy subset beside a complete face of the same family that has its own Thai mark +/// positioning (as Load font adds): what the subset lacks comes from the complete face, in its +/// codes, rather than the edit being refused. +#[test] +fn a_legacy_subset_is_completed_by_a_positioned_face_of_the_same_family() { + let mut without_kho_khwai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khwai, 217); + let mut complete = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + let count = u16::from_be_bytes(complete[4..6].try_into().unwrap()) as usize; + let records: Vec<_> = complete[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|r| &r[..4] != b"cmap").map(|r| r.to_vec()).collect(); + complete[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (index, record) in records.iter().enumerate() { + complete[12 + index * 16..28 + index * 16].copy_from_slice(record); + } + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let legacy = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &without_kho_khwai, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(legacy)); + let full = legacy_thai_font(&mut doc, "THSarabunPSK", &complete, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(full)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).expect("the complete face has ค"); + assert_eq!(edited.substituted, None); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กขค"); + assert!(shown_codes(&doc).contains(&("F2".into(), vec![0x00, 0xd9])), "ค from the complete face: {:?}", shown_codes(&doc)); +} + +/// `program` with its `cmap` replaced by one mapping Thai to the legacy THSarabunPSK glyph order +/// (U+0E01–U+0E3A to glyphs 214–271, U+0E3F–U+0E5B to 272–298), as a complete face's own does, +/// and A–Z to glyphs 36–61 (any outlines do: only what the program covers matters here). +fn with_legacy_thai_cmap(mut program: Vec) -> Vec { + let groups = [(0x41u32, 0x5au32, 36u32), (0x0e01, 0x0e3a, 214), (0x0e3f, 0x0e5b, 272)]; + let mut cmap: Vec = [0u16, 1, 3, 10].iter().flat_map(|v| v.to_be_bytes()).collect(); + cmap.extend(12u32.to_be_bytes()); + cmap.extend(12u16.to_be_bytes()); + cmap.extend(0u16.to_be_bytes()); + for value in [16 + 12 * groups.len() as u32, 0, groups.len() as u32] { + cmap.extend(value.to_be_bytes()); + } + for (first, last, glyph) in groups { + cmap.extend([first, last, glyph].iter().flat_map(|v| v.to_be_bytes())); + } + program.resize(program.len().next_multiple_of(4), 0); + let count = u16::from_be_bytes(program[4..6].try_into().unwrap()) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|&r| &program[r..r + 4] == b"cmap").unwrap(); + let at = program.len() as u32; + program[record + 8..record + 12].copy_from_slice(&at.to_be_bytes()); + program[record + 12..record + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + program.extend(cmap); + program +} + +/// A legacy subset beside a complete face of the same family that can't be reused in its +/// existing codes (its encoding is a CMap stream, as Load font writes for a CFF face): the span +/// the subset can't show is shaped by the complete program with its own layout, all of it in +/// that one face, instead of being refused. +#[test] +fn a_legacy_subset_is_completed_by_a_complete_program_it_cannot_reuse_in_place() { + let mut without_kho_khwai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khwai, 217); + let complete = with_legacy_thai_cmap(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let legacy = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &without_kho_khwai, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(legacy)); + let mut full = legacy_thai_font(&mut doc, "THSarabunPSK", &complete, LEGACY_THAI_UNICODE); + let encoding = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"begincmap 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange endcmap", + ))); + full.set(b"Encoding".to_vec(), Object::Ref(encoding)); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(full)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).expect("the complete program shapes what the subset lacks"); + assert_eq!(edited.substituted, None); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "กขค"); + assert!( + lines.iter().all(|l| pdfcraft_fonts::embedding::face_key(&l.base_font) == "thsarabunpsk"), + "the same face throughout: {:?}", + lines.iter().map(|l| &l.base_font).collect::>() + ); + assert!(shown_codes(&doc).iter().all(|(font, _)| font != "F1"), "no cluster split from the subset: {:?}", shown_codes(&doc)); +} + +/// The complete program that takes over a span shapes all of its rows, a Latin-only one too, +/// even where its existing PDF encoding has no Latin; and it must have the source's own style: +/// a Regular program never completes text that the subset's descriptor marks bold. +#[test] +fn a_complete_program_takes_the_whole_span_only_in_the_source_style() { + let fixture = |bold: bool| { + let mut without_kho_khwai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khwai, 217); + let complete = with_legacy_thai_cmap(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let mut legacy = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &without_kho_khwai, LEGACY_THAI_UNICODE); + if bold { + let Some(Object::Array(mut descendants)) = legacy.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(descendant)) = descendants.first_mut() else { panic!("fixture") }; + let Some(Object::Dict(mut descriptor)) = descendant.get(b"FontDescriptor").cloned() else { panic!("fixture") }; + descriptor.set(b"FontWeight".to_vec(), Object::Int(700)); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + legacy.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + } + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(legacy)); + let mut full = legacy_thai_font(&mut doc, "THSarabunPSK", &complete, LEGACY_THAI_UNICODE); + let encoding = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"begincmap 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange endcmap", + ))); + full.set(b"Encoding".to_vec(), Object::Ref(encoding)); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(full)); + doc + }; + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let mut doc = fixture(false); + text::rewrite_block(&mut doc, 0, 0, Some("กขค\nABC"), &style).expect("every row shaped by the complete program"); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, "กขค\nABC"); + assert!(shown_codes(&doc).iter().all(|(font, _)| font != "F1" && font != "F2"), "{:?}", shown_codes(&doc)); + let mut doc = fixture(true); + assert!(text::text_lines(&doc, 0).unwrap()[0].bold, "the descriptor makes the subset bold"); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + assert!(text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).is_err(), "no Regular program for bold text"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); +} + +/// Exporters set the descriptor's Italic flag on upright faces (seen on a THSarabunPSK header +/// subset: `/Flags 96`, `/ItalicAngle 0`). Such a subset is still the same style as its upright +/// siblings, so they complete what it lacks; a real slant (`/ItalicAngle -12`) is not. +#[test] +fn an_italic_flag_alone_does_not_separate_subsets_of_one_face() { + for (angle, completes) in [(0, true), (-12, false)] { + let mut without_kho_khwai = legacy_thai_program(); + drop_sfnt_glyph(&mut without_kho_khwai, 217); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let mut flagged = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &without_kho_khwai, LEGACY_THAI_UNICODE); + let Some(Object::Array(mut descendants)) = flagged.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(descendant)) = descendants.first_mut() else { panic!("fixture") }; + let Some(Object::Dict(mut descriptor)) = descendant.get(b"FontDescriptor").cloned() else { panic!("fixture") }; + descriptor.set(b"Flags".to_vec(), Object::Int(96)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(angle)); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + flagged.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(flagged)); + let upright = legacy_thai_font(&mut doc, "GHIJKL+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(upright)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style); + assert_eq!(edited.is_ok(), completes, "ItalicAngle {angle}: {:?}", edited.err()); + if completes { + assert!(shown_codes(&reopen(&doc)).contains(&("F2".into(), vec![0x00, 0xd9])), "ค from the upright sibling"); + } + } +} + +/// A subset keeps only what its document used. One without อ or any tone alternate still takes +/// text that needs neither, in the source face; text that needs an alternate it lacks is refused. +#[test] +fn editing_legacy_thai_needs_only_the_glyphs_the_text_uses() { + let mut sparse = legacy_thai_program(); + for gid in [258, 347, 352, 353] { + drop_sfnt_glyph(&mut sparse, gid); + } + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &sparse, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let error = text::rewrite_block(&mut doc, 0, 0, Some("ปู่"), &style).unwrap_err().to_string(); + assert!(error.contains("contextual mark") && error.contains("U+0E48"), "{error}"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).expect("every glyph this text needs is in the subset"); + assert_eq!(edited.substituted, None); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กขค"); + assert!(shown_codes(&doc).iter().all(|(font, _)| font == "F1"), "{:?}", shown_codes(&doc)); +} + +/// A subset may give a character a code whose glyph it emptied (its `/W` still lists it). The +/// edit then uses another subset of the face that draws it, rather than drawing nothing. +#[test] +fn editing_legacy_thai_skips_a_subset_whose_glyph_is_empty() { + // Both subsets map A (glyph 36 in Inter) through ToUnicode; F1 has emptied it. + let unicode = [LEGACY_THAI_UNICODE, b" 1 beginbfchar <0024> <0041> endbfchar"].concat(); + let mut empty_a = legacy_thai_program(); + drop_sfnt_glyph(&mut empty_a, 36); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let first = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &empty_a, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(first)); + let second = legacy_thai_font(&mut doc, "GHIJKL+THSarabunPSK", &legacy_thai_program(), &unicode); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(second)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กA"), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กA"); + let shown = shown_codes(&doc); + assert!(shown.contains(&("F2".into(), vec![0x00, 0x24])), "A from the subset that has its outline: {shown:?}"); + assert!(!shown.contains(&("F1".into(), vec![0x00, 0x24])), "not from the one that emptied it: {shown:?}"); +} + +/// Each two-byte code a page's content shows, with the font resource it is shown in. +fn shown_codes(doc: &Document) -> Vec<(String, Vec)> { + let mut font = String::new(); + let mut shown = Vec::new(); + for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops { + if op.is("Tf") { + font = String::from_utf8_lossy(op.name(0).unwrap_or_default()).into_owned(); + } + let strings: Vec> = match op.op.as_slice() { + b"Tj" | b"'" => op.operands.last().and_then(Object::as_string).map(|s| s.bytes.clone()).into_iter().collect(), + b"TJ" => { + op.operands.first().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_string).map(|s| s.bytes.clone()).collect() + } + _ => Vec::new(), + }; + for bytes in strings { + shown.extend(bytes.chunks(2).map(|c| (font.clone(), c.to_vec()))); + } + } + shown +} + +/// Where table `tag` starts in an sfnt. +fn sfnt_table(program: &[u8], tag: &[u8; 4]) -> usize { + let count = u16::from_be_bytes(program[4..6].try_into().unwrap()) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|&r| &program[r..r + 4] == tag).unwrap(); + u32::from_be_bytes(program[record + 8..record + 12].try_into().unwrap()) as usize +} + +/// Empty glyph `gid` of an sfnt as a subsetter drops it: its data stays as the previous glyph's +/// padding, and every other glyph keeps its outline. +fn drop_sfnt_glyph(program: &mut [u8], gid: usize) { + let long = program[sfnt_table(program, b"head") + 51] == 1; + let (loca, size) = (sfnt_table(program, b"loca"), if long { 4 } else { 2 }); + let next = program[loca + (gid + 1) * size..][..size].to_vec(); + program[loca + gid * size..][..size].copy_from_slice(&next); +} + +/// Inter's outlines in a legacy THSarabunPSK subset's glyph order; no proprietary font fixture is +/// needed. The slots where that order has a Thai vowel or tone mark, or a mark-like Windows PUA +/// alternate, take no advance (the source font is checked for that before its glyphs are trusted) +/// and the others 500 units. Removing the layout and `cmap` tables reproduces legacy subsetting. +fn legacy_thai_program() -> Vec { + let mut program = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + let hmtx = sfnt_table(&program, b"hmtx"); + for gid in (214..=298usize).chain(342..=368) { + let mark = match gid { + 342..=368 => !matches!(gid, 342 | 357), + _ => matches!(gid + if gid <= 271 { 0xd2b } else { 0xd2f }, 0x0e31 | 0x0e34..=0x0e3a | 0x0e47..=0x0e4e), + }; + let advance: u16 = if mark { 0 } else { 500 }; + program[hmtx + gid * 4..hmtx + gid * 4 + 2].copy_from_slice(&advance.to_be_bytes()); + } + 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 @@ -10855,6 +11327,38 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft for (index, record) in records.iter().enumerate() { program[12 + index * 16..28 + index * 16].copy_from_slice(record); } + program +} + +/// A THSarabunPSK Identity-H font over `program` (a [`legacy_thai_program`]), mapping `unicode`. +fn legacy_thai_font(doc: &mut Document, base_font: &str, program: &[u8], unicode: &[u8]) -> Dict { + 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(), unicode))); + 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(base_font)); + 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)); + font +} + +/// The intact ToUnicode of a legacy THSarabunPSK subset: nominal Thai and two tone alternates. +const LEGACY_THAI_UNICODE: &[u8] = + b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + +#[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 program = legacy_thai_program(); 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))); diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index 71339af64b..dff7440145 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -20,6 +20,10 @@ pub struct Preparation { pub repaired_fonts: usize, } +/// How many levels of nested groups preparation opens on one page. Text nested deeper stays +/// grouped and is reported in [`Preparation::refused`]. +const MAX_LEVELS: usize = 8; + /// 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 { @@ -28,7 +32,8 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result>()); @@ -63,11 +68,18 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result { - // 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")) { + // The resource name exactly as the `Do` writes it: a name need not be UTF-8, and + // `image.name` is only its display form. + let name = op.last_name().map_or_else(|| image.name.as_bytes().to_vec(), <[u8]>::to_vec); + match expand::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + Ok(expand::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => { + // Vector and image artwork needs no preparation. Nested text groups are + // visited on the next pass, with their inherited placement and clip in force. + if !shows_text { + continue; + } + if pass == MAX_LEVELS { + result.refused.push(format!("{}: its text is grouped more than {MAX_LEVELS} levels deep", image.name)); continue; } let data_len = pdfcraft_content::serialize_ops(&inline).len(); @@ -140,8 +152,10 @@ fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result <{:04x}>\n", u32::from(ch))); diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs index 27cbf1b48e..bf01e201d1 100644 --- a/crates/edit/src/text/forms/expand.rs +++ b/crates/edit/src/text/forms/expand.rs @@ -11,11 +11,13 @@ //! 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`. +//! mask, or a group with a blending colour space (`/CS`) the page group doesn't name too: its +//! contents composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they +//! would composite in the page's, which a page without one leaves to the PDF processor. 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 @@ -42,10 +44,20 @@ 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)>); +/// What [`ungroup_ops`] gives back. +pub(crate) struct Ungrouped { + /// The ops that draw the form inline. + pub(crate) ops: Vec, + /// The page resources with the form's merged in. + pub(crate) resources: Dict, + /// The pattern copies to add to the document, each under the name in the merged `/Pattern` + /// dictionary that the ops already use. + pub(crate) patterns: Vec<(Vec, Object)>, + /// Whether the form shows text, in its own content or in a Form XObject it draws (at any + /// depth [`MAX_NESTING`] allows). Text inside a tiling pattern or Type 3 glyph doesn't count: + /// inlining the form doesn't make it editable. Image-only and vector-only forms are artwork. + pub(crate) shows_text: bool, +} /// 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 @@ -122,14 +134,15 @@ pub(crate) fn ungroup_ops( return Err(refuse("its content has parts that can't be read")); } let mut ops = parsed.ops; - { + let shows_text = { // 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 }; + let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false }; 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")); } - } + walk.text + }; // Merge the form's resources into a copy of the page's, renaming what clashes. let mut merged = resources.clone(); @@ -197,6 +210,13 @@ pub(crate) fn ungroup_ops( b"ET" => in_text = false, _ => {} } + // A name is renamed at the operand position below, but the renderer takes operands from + // the end and ignores surplus ones before them: with a surplus operand the drawn name + // would keep its old spelling while its resource moved, and draw nothing or the wrong + // thing. The parser keeps such operands without counting them as skipped. + if takes_operands(&op.op).is_some_and(|n| op.operands.len() > n) { + return Err(refuse("its content gives an operator more operands than it takes")); + } 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() { @@ -345,7 +365,7 @@ pub(crate) fn ungroup_ops( if layer.is_some() { out.push(Op::new("EMC", Vec::new())); } - Ok((out, merged, patterns)) + Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text }) } /// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead @@ -389,13 +409,30 @@ const MAX_NESTING: usize = 8; 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. +/// form drawing itself), the answers for each form with resources of its own (whether it blends +/// and whether it shows text; neither depends on who draws it), how many reads it has made, and +/// whether the content being walked shows text itself or through a Form XObject it draws. struct BlendWalk<'a> { path: Vec, - known: HashMap, + known: HashMap, reads: usize, budget: &'a mut DecodeBudget, + text: bool, +} + +/// Whether `op` shows text (ISO 32000-2 §9.4.3, Table 107). +fn shows_text(op: &Op) -> bool { + matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") +} + +/// How many operands an operator that names a resource takes (ISO 32000-2 Annex A), its name +/// being the first of them: only for those whose name is read at a fixed position here. +fn takes_operands(op: &[u8]) -> Option { + match op { + b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => Some(1), + b"Tf" | b"BDC" | b"DP" => Some(2), + _ => None, + } } /// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from @@ -430,11 +467,21 @@ fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { /// 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. +/// Sets `walk.text` when `ops` show text, or a Form XObject they draw does; text in a tiling +/// pattern's cell is not the page's text and is not counted. 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)); + if ops.iter().any(shows_text) { + walk.text = true; + } for op in ops { + // Read at operand 0 below; with a surplus operand the renderer draws another name, which + // this walk can't vouch for. + if takes_operands(&op.op).is_some_and(|n| op.operands.len() > n) { + return true; + } if op.is("gs") && state_blends(op.name(0).and_then(|n| states.get(n))) { return true; } @@ -466,10 +513,12 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal _ => continue, }; let r = o.as_ref(); - if let Some(known) = r.and_then(|r| walk.known.get(&r)) { - if *known { + let drawn_form = op.is("Do"); + if let Some((known, text)) = r.and_then(|r| walk.known.get(&r)).copied() { + if known { return true; } + walk.text |= drawn_form && text; continue; } if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS { @@ -487,11 +536,14 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal if let Some(r) = r { walk.path.push(r); } + let outer_text = std::mem::replace(&mut walk.text, false); let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk); + let inner_text = walk.text; + walk.text = outer_text || (drawn_form && inner_text); if let Some(r) = r { walk.path.retain(|x| *x != r); if has_own { - walk.known.insert(r, blends); + walk.known.insert(r, (blends, inner_text)); } } if blends { @@ -508,14 +560,13 @@ pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option { 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. +/// Whether a group blending in `cs` composites as the page does: only when the page group names +/// the same space. A page that names none composites in a space the PDF processor chooses +/// (ISO 32000-2 §11.4.7): DeviceRGB on screen, but often DeviceCMYK when printed, where a group +/// that blends in DeviceRGB converts its CMYK and gray colours through RGB and inline they would +/// not be. Such a group is refused rather than changed. 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"), - } + page_space.is_some_and(|page| *doc.resolve(cs) == *page) } /// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 5d8075785e..84ec8c75cc 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -107,13 +107,34 @@ impl FontResolver<'_> { struct LegacyFace { glyphs: Option, metrics: Metrics, + /// Weight and slant ([`FontStyle::of`]). + style: FontStyle, } struct PreparedCharacters { values: Vec, substituted: Option, - outlined_thai: std::collections::HashSet, - legacy_thai: HashMap, + thai: ThaiShaping, +} + +/// How the Thai spans of an edit are shaped, by the font name their characters carry. +#[derive(Default)] +struct ThaiShaping { + /// Draft-only names drawn with the bundled Thai outlines. + outlined: std::collections::HashSet, + /// Source faces shaped in their existing codes, by resource name. + legacy: HashMap, + /// Draft-only names whose rows, Latin ones included, are shaped and embedded with the + /// complete program of this same-face resource: a span its subsets can't show in place. + program: HashMap, +} + +/// How a Thai span keeps its source face ([`legacy_thai_font`]). +enum ThaiSpan { + /// In this resource's existing codes. + InPlace(String), + /// Shaped by this same-face resource's complete program with its own layout. + Program(String), } struct CharacterPreparation<'a> { @@ -133,8 +154,7 @@ 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 thai = ThaiShaping::default(); let mut chars = Vec::new(); let mut substituted = None; let mut fallback = FallbackBuilder::new(max_glyphs); @@ -146,13 +166,17 @@ impl CharacterPreparation<'_> { // 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()))?; + let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| { + EditError::Invalid(format!( + "the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists" + )) + })?; 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 - ))); + let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); + return Err(EditError::Invalid(match missing { + Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)), + None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family), + })); } substituted = Some(face.family.to_owned()); true @@ -173,7 +197,7 @@ impl CharacterPreparation<'_> { }), }; match chosen { - Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut legacy_thai)?, + Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy)?, None => None, } } else { @@ -194,11 +218,27 @@ impl CharacterPreparation<'_> { { resolver.metrics.insert(name.clone(), m); } - outlined_thai.insert(name.clone()); + thai.outlined.insert(name.clone()); Some(name) } else { None }; + let (existing_thai, program_name) = match existing_thai { + Some(ThaiSpan::InPlace(name)) => (Some(name), None), + Some(ThaiSpan::Program(resource)) => { + // Draft-only too: another span may still show this resource's existing codes. + let mut name = format!("PCProgramThai{index}"); + while resolver.metrics.contains_key(&name) { + name.push('_'); + } + if let Some(m) = resolver.metrics.get(&source.font).cloned() { + resolver.metrics.insert(name.clone(), m); + } + thai.program.insert(name.clone(), resource); + (None, Some(name)) + } + None => (None, None), + }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { if ch == '\n' { @@ -212,7 +252,7 @@ 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) = &outline_name { + let name = if let Some(name) = outline_name.as_ref().or(program_name.as_ref()) { name.clone() } else if let Some(name) = &existing_thai { name.clone() @@ -245,7 +285,7 @@ impl CharacterPreparation<'_> { } substituted = Some(written.family.to_owned()); } - Ok(PreparedCharacters { values: chars, substituted, outlined_thai, legacy_thai }) + Ok(PreparedCharacters { values: chars, substituted, thai }) } } @@ -324,8 +364,7 @@ struct ShapedRows { fn prepare_shaped_rows( doc: &Document, fonts: &Dict, - outlined_thai: &std::collections::HashSet, - legacy_thai: &HashMap, + thai: &ThaiShaping, rows: &[Vec], kept: &[bool], advance: impl Fn(&StyledChar) -> f64, @@ -342,7 +381,7 @@ 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(shaped) = shape_source(doc, fonts, outlined_thai, legacy_thai, &item.2, &text)? { + if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? { runs.insert((row_index, at), shaped); } at = end; @@ -791,7 +830,7 @@ fn legacy_thai_font( source: &TextLine, span: &str, cache: &mut HashMap, -) -> Result, EditError> { +) -> 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)) { @@ -808,13 +847,39 @@ fn legacy_thai_font( 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 }); + let style = FontStyle::of(doc, &font, &metrics); + entry.insert(LegacyFace { glyphs, metrics, style }); } } - for row in span.split(['\n', '\r']).filter(|row| !row.is_empty()) { - shape_existing_thai(cache, &source.font, row)?; + // A face none of whose subsets can be shaped in place (no Thai, or neither positioned nor in + // the legacy glyph order) is shaped like any other source font (`shape_source`). + if !has_thai_source_face(cache, &family) { + return Ok(None); } - Ok(Some(source.font.clone())) + let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty()); + for row in rows() { + if let Err(error) = shape_existing_thai(cache, &source.font, row) { + // Not in the face's existing codes. A complete program of the face that shapes every + // row with its own layout (one Load font added as CFF, or whose contextual glyphs it + // gave no code yet) takes the whole span, so no cluster is split between fonts. + let Some(source_font) = fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)) else { return Err(error) }; + let complete = program_candidates(doc, fonts, &source.font, source_font, &source.base_font) + .into_iter() + .find(|(_, font)| rows().all(|row| shape_with_program(doc, font, row).is_some())); + return match complete { + Some((name, _)) => Ok(Some(ThaiSpan::Program(name))), + None => Err(error), + }; + } + } + Ok(Some(ThaiSpan::InPlace(source.font.clone()))) +} + +/// Whether a subset of the face `family` (a [`face_key`](pdfcraft_fonts::embedding::face_key)) +/// in `cache` can be shaped in place: a THSarabunPSK program with its own Thai mark positioning, +/// or a verified legacy positional one. +fn has_thai_source_face(cache: &HashMap, family: &str) -> bool { + cache.values().any(|face| face.glyphs.is_some() && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family) } /// A visual row may use several subsets of the same exact face (often a separate subset for @@ -829,7 +894,9 @@ fn shape_existing_thai(cache: &HashMap, primary: &str, text: candidates.extend( cache .iter() - .filter(|(name, face)| name.as_str() != primary && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family) + .filter(|(name, face)| { + name.as_str() != primary && face.style == first.style && 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. @@ -851,42 +918,42 @@ fn shape_existing_thai(cache: &HashMap, primary: &str, text: 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(|| { + let unshaped = |why: String| { 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); + }; + // The whole run in one subset keeps any kerning between its clusters. A subsetter may + // instead have split the run's glyphs across subsets of the face: then each cluster + // (a base and the marks on it, which shape together) comes from a subset that has it. + let pieces = match shape_in_one_subset(&candidates, segment) { + Ok(whole) => vec![whole], + Err(_) => thai_clusters(segment) + .into_iter() + .map(|cluster| shape_in_one_subset(&candidates, cluster).map_err(unshaped)) + .collect::, _>>()?, + }; + for (name, shaped) in pieces { + 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; } - 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 first = face.metrics.codes(&code).first()?.0; + // `encode` accepts a CID the subset emptied (#1443): drawing it would show nothing. + // Try the next subset; a space then falls back to its measured gap below. + if face.metrics.has_outline(first) == Some(false) { + return None; + } + Some((*name, code, face.metrics.width(first))) }); let (name, code, width) = if let Some(found) = found { found @@ -919,10 +986,45 @@ fn shape_existing_thai(cache: &HashMap, primary: &str, text: Ok(ShapedText { run, font: ShapedFont::Existing(resources) }) } +/// Shape `text` in the first candidate subset that has every glyph it needs, and name it. The +/// error is the first subset's reason (empty when no candidate has a usable glyph program). +fn shape_in_one_subset<'a>(candidates: &[(&'a str, &LegacyFace)], text: &str) -> Result<(&'a str, pdfcraft_fonts::pdf::ExistingThaiRun), String> { + let mut why = String::new(); + for (name, face) in candidates { + let Some(font) = &face.glyphs else { continue }; + match font.shape(text) { + Ok(shaped) => return Ok((name, shaped)), + Err(e) if why.is_empty() => why = e, + Err(_) => {} + } + } + Err(why) +} + +/// `text` (a run of Thai characters) cut before each character that doesn't attach to the one +/// before it: a cluster is a base or leading vowel with the vowels and tone marks above and below +/// it, and Sara Am, whose Nikhahit sits on the base. Legacy presentation forms (U+F700–U+F71A) +/// are kept with what precedes them. +fn thai_clusters(text: &str) -> Vec<&str> { + let attaches = |c: char| matches!(u32::from(c), 0x0e31 | 0x0e33..=0x0e3a | 0x0e47..=0x0e4e | 0xf700..=0xf71a); + let mut starts: Vec = text.char_indices().filter(|(at, c)| *at == 0 || !attaches(*c)).map(|(at, _)| at).collect(); + starts.push(text.len()); + // Each boundary is a `char_indices` position or the end, strictly increasing: every `get` + // succeeds and no cluster is empty. + starts.windows(2).filter_map(|w| text.get(*w.first()?..*w.get(1)?)).collect() +} + /// 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 }; + // The bundled face is Regular and upright. A source its descriptor or name marks bold or + // italic keeps that style, so completing it with these outlines would be a silent change: + // it needs an explicit choice instead. (Variable-font instances often keep the family name.) + let style = fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)).map(|f| FontStyle::of(doc, &f, &Metrics::from_dict(doc, &f))); + if style.map_or(source.bold || source.italic, |s| s.bold || s.italic) { + 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) @@ -972,46 +1074,98 @@ fn shape_thai_row(text: &str) -> Result Vec<(String, Dict)> { + let key = pdfcraft_fonts::embedding::face_key(base_font); + let style = FontStyle::of(doc, &source, &Metrics::from_dict(doc, &source)); + let mut candidates = vec![(name.to_owned(), source)]; + candidates.extend( + fonts + .iter() + .filter_map(|(n, f)| Some((String::from_utf8_lossy(n).into_owned(), doc.dict(f)?))) + .filter(|(_, f)| { + let m = Metrics::from_dict(doc, f); + pdfcraft_fonts::embedding::face_key(&m.base_font) == key && FontStyle::of(doc, f, &m) == style + }) + .take(128), + ); + candidates +} + +/// A font's weight and slant, for matching subsets and complete programs of one face: a Regular +/// program never completes text that a descriptor marks bold, say. +#[derive(Clone, Copy, Debug, PartialEq)] +struct FontStyle { + bold: bool, + italic: bool, +} + +impl FontStyle { + /// Bold from the name or the descriptor's weight ([`Metrics`]); italic from the name or a + /// non-zero `/ItalicAngle`. The descriptor's Italic flag alone isn't trusted: exporters set + /// it on upright faces (a THSarabunPSK subset with `/Flags 96` and `/ItalicAngle 0`). + fn of(doc: &Document, font: &Dict, metrics: &Metrics) -> Self { + let descendant = + font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first()).cloned()).and_then(|d| doc.dict(&d)); + let descriptor = descendant.as_ref().unwrap_or(font).get(b"FontDescriptor").and_then(|d| doc.dict(d)); + let angle = descriptor.as_ref().and_then(|d| d.get(b"ItalicAngle")).and_then(|a| doc.resolve(a).as_f64()).unwrap_or(0.0); + let name = metrics.base_font.to_ascii_lowercase(); + Self { bold: metrics.bold, italic: name.contains("italic") || name.contains("oblique") || (angle.is_finite() && angle.abs() > 0.1) } + } +} + +/// `text` shaped by `font`'s embedded program with its own layout, when the program covers it. +fn shape_with_program(doc: &Document, font: &Dict, text: &str) -> Option<(Vec, pdfcraft_fonts::source_shaping::ShapedRun)> { + let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, font)?; + if !pdfcraft_fonts::embedding::covers(&bytes, text) { + return None; + } + let shaped = pdfcraft_fonts::source_shaping::shape(&bytes, text).ok()?; + Some((bytes, shaped)) +} + /// 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, EditError> { - if outlined_thai.contains(name) { +fn shape_source(doc: &Document, fonts: &Dict, thai: &ThaiShaping, name: &str, text: &str) -> Result, EditError> { + if thai.outlined.contains(name) { let shaped = shape_thai_row(text)?; return Ok(Some(ShapedText { run: shaped, font: ShapedFont::ThaiOutlines })); } + if let Some(resource) = thai.program.get(name) { + let font = fonts + .get(resource.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the complete font resource is missing".into()))?; + let family = Metrics::from_dict(doc, &font).base_font; + let (bytes, shaped) = shape_with_program(doc, &font, text) + .ok_or_else(|| EditError::Invalid(format!("{family} cannot shape this text with its own layout; choose a font that covers it")))?; + return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } })); + } 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 metrics = Metrics::from_dict(doc, &source); - if metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font).starts_with("THSarabunPSK") + let family = Metrics::from_dict(doc, &source).base_font; + // In the face's existing codes first; what they can't show, a complete program of the face + // may (`legacy_thai_font` already gave the span to one that can). + let in_place = (family.rsplit('+').next().unwrap_or(&family).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 - .iter() - .filter_map(|(_, f)| doc.dict(f)) - .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), - ); - for font in candidates { - if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) - && pdfcraft_fonts::embedding::covers(&bytes, text) - && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) - { + && has_thai_source_face(&thai.legacy, &pdfcraft_fonts::embedding::face_key(&family))) + .then(|| shape_existing_thai(&thai.legacy, name, text)); + let in_place = match in_place { + Some(Ok(shaped)) => return Ok(Some(shaped)), + Some(Err(error)) => Some(error), + None => None, + }; + for (_, font) in program_candidates(doc, fonts, name, source, &family) { + if let Some((bytes, shaped)) = shape_with_program(doc, &font, text) { return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } })); } } + if let Some(error) = in_place { + return Err(error); + } 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)) @@ -1302,8 +1456,7 @@ pub(crate) fn rewrite_styled_block( max_glyphs, } .prepare()?; - let outlined_thai = prepared.outlined_thai; - let legacy_thai = prepared.legacy_thai; + let thai = prepared.thai; let chars = prepared.values; let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { @@ -1354,7 +1507,7 @@ 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, &outlined_thai, &legacy_thai, &item.2, &string)? { + if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? { let shaped = &shaped.run; let source = lines.get(item.1).unwrap_or(first); let scale = @@ -1406,7 +1559,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, &outlined_thai, &legacy_thai, &rows, &kept, advance)?; + let shaped = prepare_shaped_rows(doc, &fonts, &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; diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 6368e9097c..93bedb6645 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -194,6 +194,9 @@ pub(crate) struct EditCtx { /// The overlay streams the edit's applied redactions added, which alone the renderer's check /// leaves out. pub(crate) redaction_overlays: Vec, + /// What [`Edit::PrepareText`] prepared, repaired and left grouped, for + /// [`Session::prepare_text`](crate::Session::prepare_text). + pub(crate) prepared: Option, } impl EditCtx { @@ -223,6 +226,7 @@ impl EditCtx { rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), + prepared: None, } } @@ -734,6 +738,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()), )); } + cx.prepared = Some(result); } Edit::RegisterTextFont { page, bytes } => { pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index be7fd5aca5..eaf2583ca6 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -20,6 +20,19 @@ fn deletes_pages(edit: &Edit) -> bool { impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + self.apply_reporting(id, edit).map(|_| ()) + } + + /// Edit a PDF ▸ Edit text: make the grouped text on `page` (0-based) editable, as one undoable + /// [`Edit::PrepareText`], and say what it prepared, which fonts' maps it repaired and which + /// groups it left as they were (and why). The counts are this edit's own, not a preview's. + pub fn prepare_text(&mut self, id: DocId, page: usize) -> Result { + self.apply_reporting(id, Edit::PrepareText { page })? + .ok_or_else(|| EditError::Invalid("preparing grouped text gave no result; the document was not changed".into())) + } + + /// [`Session::apply`], giving back what an [`Edit::PrepareText`] did (`None` for other edits). + fn apply_reporting(&mut self, id: DocId, edit: Edit) -> Result, EditError> { let now = self.now(); let today = self.today(); let js_off = self.js_off; @@ -61,7 +74,7 @@ impl Session { }; // Nothing to bake: leave undo history and the dirty flag alone. if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); + return Ok(None); } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { @@ -228,7 +241,7 @@ impl Session { doc.xfa_scripts_off = true; doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); } - Ok(()) + Ok(cx.prepared) } pub fn undo(&mut self, id: DocId) -> Result { diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 4fab8a0313..75c56625c8 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -938,6 +938,19 @@ impl Metrics { } } + /// Whether the embedded program draws an outline for `code`. Known (`Some`) only for a subset + /// Type 0 font over a readable TrueType program, where a dropped glyph is left empty; `None` + /// when the program can't tell, for example a simple or CFF font. [`Metrics::encode`] accepts + /// any code with a `/W` entry (#1443), so a caller that would rather use another subset of the + /// same face than draw nothing asks this too. An empty space glyph is normal. + pub fn has_outline(&self, code: u32) -> Option { + if !self.composite || self.outlined_cids.is_empty() { + return None; + } + let cid = self.cid(code); + Some(self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1)) + } + /// Whether `code` shows exactly `c` and has a glyph for it. fn shows(&self, code: u32, c: char) -> bool { self.unicode.text(code).is_some_and(|t| t.chars().eq(std::iter::once(c))) && self.has_glyph(code) @@ -1561,4 +1574,4 @@ mod encoding_tests { mod thai; use thai::composite_glyph_maps; -pub use thai::{LegacyThaiFont, recovered_thai_map}; +pub use thai::{ExistingThaiRun, LegacyThaiFont, recovered_thai_map}; diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 54784c291c..8596d58734 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -3,7 +3,9 @@ //! Some THSarabunPSK Identity-H subsets retain original glyph IDs but write Latin/control //! destinations for Thai in ToUnicode (including an accidental 00ff..01ff range). The intact //! Thai anchors identify the original nominal Thai order. Only that complete fingerprint is -//! accepted; correct maps and unrelated fonts are never changed. No font artwork is bundled. +//! accepted, and then only when every glyph the subset kept advances as its assumed character +//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font +//! artwork is bundled. use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; use pdfcraft_cos::{DecodeBudget, Object}; @@ -12,6 +14,7 @@ use std::collections::BTreeMap; /// Recover the nominal Thai codes and the presentation marks of this damaged legacy export. /// The caller must record the repair, and attach the map to a copy of the page's font. pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option> { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let base = String::from_utf8_lossy(font.name(b"BaseFont")?); let family = base.rsplit('+').next()?; if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic") @@ -101,14 +104,12 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option = (214u16..=298) - .filter_map(|gid| { - let unicode = u32::from(gid) + if gid <= 271 { 0xd2b } else { 0xd2f }; - char::from_u32(unicode).map(|ch| (gid, ch)) - }) - .collect(); + let nominal = (214u16..=298).filter_map(|gid| { + let unicode = u32::from(gid) + if gid <= 271 { 0xd2b } else { 0xd2f }; + char::from_u32(unicode).map(|ch| (gid, ch)) + }); // Contextual marks have the same character as the nominal mark, with a moved outline. - out.extend([ + let contextual = [ (343, '\u{0e34}'), (344, '\u{0e35}'), (347, '\u{0e48}'), @@ -118,10 +119,54 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option start) { + continue; + } + if advances.advance_width(skrifa::GlyphId::new(u32::from(gid))).is_none_or(|w| (w == 0.0) != is_thai_mark(ch)) { + return None; + } + out.push((gid, ch)); + } Some(out) } +/// Whether a Thai character is a vowel or tone mark set above or below its base, with no advance +/// of its own (U+0E31, U+0E34–U+0E3A, U+0E47–U+0E4E). Sara Am and the other vowels advance. +fn is_thai_mark(ch: char) -> bool { + matches!(u32::from(ch), 0x0e31 | 0x0e34..=0x0e3a | 0x0e47..=0x0e4e) +} + +/// The glyph a legacy THSarabunPSK program draws Thai character `ch` with: U+0E01–U+0E3A are +/// glyphs 214–271 and U+0E3F–U+0E5B glyphs 272–298 (the order `recovered_thai_map` assumes). +fn legacy_glyph(ch: u32) -> Option { + match ch { + 0x0e01..=0x0e3a => ch.checked_sub(0xd2b), + 0x0e3f..=0x0e5b => ch.checked_sub(0xd2f), + _ => None, + } +} + +/// The TrueType program (`/FontFile2`) of a CIDFont, decoded within the editor's size cap. +fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option> { + let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; + let program = doc.resolve(descriptor.get(b"FontFile2")?); + let Object::Stream(program) = program.as_ref() else { return None }; + DecodeBudget::new(super::MAX_SFNT, 0).decode_within(program, super::MAX_SFNT).ok() +} + /// Source glyph positions and the original PDF character codes that draw them. Empty codes /// represent whitespace advances: no glyph is added to the font or substituted for a space. pub struct ExistingThaiRun { @@ -185,18 +230,23 @@ pub(super) fn composite_glyph_maps( // See https://linux.thai.net/~thep/th-otf/shaping.html (legacy PUA assignments). let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font); let alternate_meanings = metrics.text_of(352).is_some_and(|t| t == "่") && metrics.text_of(353).is_some_and(|t| t == "้"); + // Each alternate the subset kept must advance as its form does: U+F700 and U+F70F are ฐ and + // ญ without their descenders, the rest marks with no advance of their own. Any that doesn't + // means another glyph order, and then none is used: the wrong mark would draw silently. + let kept: Vec<(usize, u32)> = (342u32..=368).enumerate().filter(|&(_, gid)| outlined(gid)).collect(); + let in_order = + kept.iter().all(|&(index, gid)| advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| (w > 0.0) == matches!(index, 0 | 15))); if family.starts_with("THSarabunPSK") && unicode.get(&0x0e01) == Some(&214) && unicode.get(&0x0e2d) == Some(&258) && (alternate_meanings || unicode.contains_key(&0xf70a)) + && in_order { - for (index, gid) in (342u32..=368).enumerate() { - if outlined(gid) - && let Some(code) = match &cid_map { - None => u16::try_from(gid).ok(), - Some(_) => codes.get(&gid).copied(), - } - { + for (index, gid) in kept { + if let Some(code) = match &cid_map { + None => u16::try_from(gid).ok(), + Some(_) => codes.get(&gid).copied(), + } { unicode.insert(0xf700 + index as u32, gid); codes.entry(gid).or_insert(code); } @@ -205,19 +255,26 @@ pub(super) fn composite_glyph_maps( Some((unicode, codes)) } -/// Shape legacy Thai using the PDF's own outlines and return codes for that same font. -/// No font program, family, weight or size is replaced. A subset missing a contextual glyph -/// is refused so the caller can try another subset of the same face or ask for its full font. +/// Shape Thai in a THSarabunPSK source font's own outlines and return codes for that same font. +/// No font program, family, weight or size is replaced. A program with OpenType Thai mark +/// positioning (a complete face loaded with Load font, say) is shaped by that layout; one +/// without it must be a verified legacy positional program, shaped with its nominal glyphs and +/// Windows PUA alternates. A subset missing a glyph the text needs is refused, so the caller can +/// try another subset of the same face or ask for its full font. pub struct LegacyThaiFont { bytes: Vec, codes: BTreeMap, space: f64, + /// The program positions Thai marks itself (GPOS `mark`/`mkmk`). + positioned: bool, } impl LegacyThaiFont { - /// Read and verify the source font once for all wrapping/preview measurements. + /// Read and verify the source font once for all wrapping/preview measurements. `None` for + /// another family, a font with no Thai, and a program without Thai mark positioning whose + /// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper + /// assumes can't be trusted there, so it is shaped as an ordinary source font. pub fn from_pdf(doc: &Document, font: &Dict, space: f64) -> Result, String> { - use skrifa::MetadataProvider as _; let m = Metrics::from_dict(doc, font); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); @@ -226,14 +283,24 @@ impl LegacyThaiFont { return Err("the source space width is invalid".into()); } let bytes = super::embedded_sfnt(doc, font).ok_or("the source Thai font has no readable glyph program")?; - let face = skrifa::FontRef::new(&bytes).map_err(|e| e.to_string())?; - let cmap = face.charmap(); - if cmap.map('ก').is_none() || cmap.map('อ').is_none() || cmap.map('\u{f70a}').is_none() { + let positioned = crate::source_shaping::has_thai_layout(&bytes); + let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, &mut DecodeBudget::new(1 << 17, 0)) + .ok_or("the Thai source glyph codes cannot be recovered safely")?; + // A subset keeps only the glyphs its document used, so no particular letter or alternate + // is required here: [`Self::shape`] refuses a text whose glyphs, or required alternates + // ([`require_pua`]), the subset lacks. Without its own positioning, every Thai glyph it + // does keep must be where the legacy order puts that character, or one of the PUA + // alternates for a mark (or for ฐ and ญ without their descenders). + let mut thai = unicode.range(0x0e01..=0x0e5b).peekable(); + if thai.peek().is_none() { return Ok(None); } - let (_, codes) = composite_glyph_maps(doc, font, &bytes, &m, &mut DecodeBudget::new(1 << 17, 0)) - .ok_or("the Thai source glyph codes cannot be recovered safely")?; - Ok(Some(Self { bytes, codes, space })) + let usable = positioned + || thai.all(|(&ch, &gid)| { + legacy_glyph(ch) == Some(gid) + || ((342..=368).contains(&gid) && (char::from_u32(ch).is_some_and(is_thai_mark) || matches!(ch, 0x0e0d | 0x0e10))) + }); + Ok(usable.then_some(Self { bytes, codes, space, positioned })) } /// Shape into glyphs that are already in this exact source face. @@ -242,8 +309,11 @@ impl LegacyThaiFont { if text.len() > 262_144 || text.contains(['\n', '\r']) { return Err("shape one bounded Thai source row at a time".into()); } - let face = skrifa::FontRef::new(&self.bytes).map_err(|e| e.to_string())?; - require_pua(&face.charmap(), text)?; + // Only the legacy shaper needs the PUA alternates; a positioned program moves its marks. + if !self.positioned { + let face = skrifa::FontRef::new(&self.bytes).map_err(|e| e.to_string())?; + require_pua(&face.charmap(), text)?; + } let mut shaped = crate::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 }; let mut codes = Vec::new(); // Legacy PDF subsets often omit the space glyph. Keep its measured PDF advance as a @@ -251,7 +321,11 @@ impl LegacyThaiFont { for piece in text.split_inclusive(' ') { let word = piece.strip_suffix(' ').unwrap_or(piece); if !word.is_empty() { - let run = crate::source_shaping::shape_legacy_thai(&self.bytes, word)?; + let run = if self.positioned { + crate::source_shaping::shape(&self.bytes, word)? + } else { + crate::source_shaping::shape_legacy_thai(&self.bytes, word)? + }; for mut glyph in run.glyphs { let code = self .codes @@ -330,10 +404,176 @@ fn require_pua(cmap: &skrifa::charmap::Charmap<'_>, text: &str) -> Result<(), St #[cfg(test)] mod tests { - use super::recovered_thai_map; - use pdfcraft_cos::{Dict, Document, Object}; + use super::{LegacyThaiFont, Metrics, composite_glyph_maps, is_thai_mark, recovered_thai_map}; + use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream}; use std::collections::HashMap; + /// Where table `tag` starts in an sfnt. + fn table(program: &[u8], tag: &[u8; 4]) -> usize { + let count = u16::from_be_bytes([program[4], program[5]]) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|&r| &program[r..r + 4] == tag).unwrap(); + u32::from_be_bytes(program[record + 8..record + 12].try_into().unwrap()) as usize + } + + /// Set glyph `gid`'s advance (a long `hmtx` entry). + fn set_advance(program: &mut [u8], gid: usize, advance: u16) { + let long = u16::from_be_bytes(program[table(program, b"hhea") + 34..][..2].try_into().unwrap()) as usize; + assert!(gid < long, "a long hmtx entry"); + let at = table(program, b"hmtx") + gid * 4; + program[at..at + 2].copy_from_slice(&advance.to_be_bytes()); + } + + /// Empty glyph `gid`, as a subsetter drops it: its data is left as the previous glyph's padding. + fn drop_glyph(program: &mut [u8], gid: usize) { + let long = program[table(program, b"head") + 51] == 1; + let loca = table(program, b"loca"); + if long { + let next: [u8; 4] = program[loca + (gid + 1) * 4..][..4].try_into().unwrap(); + program[loca + gid * 4..][..4].copy_from_slice(&next); + } else { + let next: [u8; 2] = program[loca + (gid + 1) * 2..][..2].try_into().unwrap(); + program[loca + gid * 2..][..2].copy_from_slice(&next); + } + } + + /// Inter's outlines standing in for a THSarabunPSK subset (no proprietary font is used): the + /// glyphs where the exporter's order has a Thai mark or a mark-like alternate take no + /// advance, the others 500 units. + fn thai_ordered_program() -> Vec { + let mut program = crate::script::INTER_REGULAR.to_vec(); + for gid in 214..=368usize { + let mark = match gid { + 214..=298 => is_thai_mark(char::from_u32(gid as u32 + if gid <= 271 { 0xd2b } else { 0xd2f }).unwrap()), + 342..=368 => !matches!(gid, 342 | 357), + _ => continue, + }; + set_advance(&mut program, gid, if mark { 0 } else { 500 }); + } + program + } + + /// A THSarabunPSK Identity-H font over `program`, with `to_unicode`. + fn thai_font(doc: &mut Document, program: &[u8], to_unicode: &[u8]) -> Dict { + let tu = doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode))); + let file = doc.add(Object::Stream(Stream::flate(Dict::new(), program))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(file)); + let mut desc = Dict::new(); + desc.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); + desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut font = Dict::new(); + font.set(b"Subtype".to_vec(), Object::name("Type0")); + font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK")); + font.set(b"Encoding".to_vec(), Object::name("Identity-H")); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + font.set(b"ToUnicode".to_vec(), Object::Ref(tu)); + font + } + + /// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai + /// codes, and the oversized range first. + const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange \ + <00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \ + <0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \ + <00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \ + <0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> endbfrange endcmap"; + + /// The anchors identify the exporter, but the glyph order is still checked glyph by glyph: a + /// mark where a letter belongs (or the reverse) anywhere in the kept glyphs refuses the whole + /// map, and a glyph the subset dropped is left unmapped whatever its filler advance says. + #[test] + fn recovered_thai_map_checks_every_kept_glyph_against_the_font_program() { + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, &thai_ordered_program(), DAMAGED); + let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); + assert_eq!(recovered[&262], 'ั'); + assert_eq!(recovered[&297], '๘'); + // 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that + // put Mai Han-akat one glyph earlier passes every anchor and only this check sees it. + let mut shifted = thai_ordered_program(); + set_advance(&mut shifted, 261, 0); + set_advance(&mut shifted, 262, 500); + let shifted = thai_font(&mut doc, &shifted, DAMAGED); + assert!(recovered_thai_map(&doc, &shifted).is_none(), "a kept glyph that advances unlike its character refuses the map"); + let mut dropped = thai_ordered_program(); + drop_glyph(&mut dropped, 262); + set_advance(&mut dropped, 262, 500); + let dropped = thai_font(&mut doc, &dropped, DAMAGED); + let recovered = recovered_thai_map(&doc, &dropped).unwrap(); + assert!(recovered.iter().all(|(gid, _)| *gid != 262), "a dropped glyph is not mapped"); + assert!(recovered.iter().any(|(gid, _)| *gid == 263)); + let mut unreadable = font.clone(); + let Some(Object::Array(desc)) = unreadable.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") }; + desc.remove(b"FontDescriptor"); + unreadable.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + assert!(recovered_thai_map(&doc, &unreadable).is_none(), "without the program nothing is verified, so nothing is mapped"); + } + + /// `program` without the tables a legacy subsetter leaves out. + fn without_tables(program: &[u8], tags: &[&[u8; 4]]) -> Vec { + let mut program = program.to_vec(); + let count = u16::from_be_bytes([program[4], program[5]]) as usize; + let records: Vec> = + program[12..12 + count * 16].chunks(16).filter(|r| !tags.iter().any(|t| &r[..4] == t.as_slice())).map(<[u8]>::to_vec).collect(); + program[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (i, record) in records.iter().enumerate() { + program[12 + i * 16..28 + i * 16].copy_from_slice(record); + } + program + } + + /// A THSarabunPSK program with its own Thai mark positioning is shaped by that layout; one + /// without it only if every Thai glyph it keeps is where the legacy order has that character + /// (the legacy shaper skips the positioning check). A subset needs no particular glyph. + #[test] + fn thai_source_programs_are_shaped_by_their_own_layout_or_as_verified_legacy() { + let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + // `Some(positioned)` when the program can be shaped in place. + let mode = |program: &[u8], unicode: &[u8]| { + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, program, unicode); + LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().map(|f| f.positioned) + }; + let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]); + assert_eq!(mode(&stripped, unicode), Some(false), "a legacy positional subset"); + let positioned = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB"]); + assert_eq!(mode(&positioned, unicode), Some(true), "a face with Thai mark positioning uses it"); + // ก at another glyph than the legacy order's. + let moved = b"2 beginbfrange <00d7> <010f> <0e02> <0110> <012a> <0e3f> endbfrange 1 beginbfchar <0024> <0e01> endbfchar"; + assert_eq!(mode(&stripped, moved), None, "out of the legacy order and without its own positioning"); + assert_eq!(mode(&positioned, moved), Some(true), "its own layout doesn't depend on the legacy order"); + // Some of the legacy glyphs, no ก or อ among them. + assert_eq!(mode(&stripped, b"1 beginbfrange <00d7> <00e0> <0e02> endbfrange"), Some(false)); + assert_eq!(mode(&stripped, b"1 beginbfchar <0024> <0041> endbfchar"), None, "no Thai at all"); + } + + /// The Windows PUA alternates are used only when each one the subset kept advances as its + /// form does: one mark out of place withdraws them all, so the shaper refuses instead of + /// drawing another mark. + #[test] + fn legacy_thai_alternates_need_their_glyph_order_confirmed() { + let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + let alternates = |program: &[u8]| { + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, program, unicode); + let metrics = Metrics::from_dict(&doc, &font); + let (map, _) = composite_glyph_maps(&doc, &font, program, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap(); + map.range(0xf700..=0xf71a).map(|(u, g)| (*u, *g)).collect::>() + }; + let ordered = alternates(&thai_ordered_program()); + assert!(ordered.contains(&(0xf70a, 352)) && ordered.contains(&(0xf710, 358)), "{ordered:?}"); + let mut misplaced = thai_ordered_program(); + set_advance(&mut misplaced, 352, 500); + assert!(alternates(&misplaced).is_empty(), "one misplaced alternate withdraws them all"); + // A dropped alternate is simply absent; the others still come from the kept glyphs. + let mut dropped = thai_ordered_program(); + drop_glyph(&mut dropped, 353); + let kept = alternates(&dropped); + assert!(kept.contains(&(0xf70a, 352)) && !kept.iter().any(|(_, g)| *g == 353), "{kept:?}"); + } + #[test] fn damaged_legacy_thai_map_recovers_consonants_vowels_and_contextual_tones_only_for_its_fingerprint() { let mut doc = Document::new_empty(); @@ -342,15 +582,8 @@ mod tests { <0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \ <00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \ <0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> <00ff><01ff><0032> endbfrange endcmap"; - let tu = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), data))); - let mut desc = Dict::new(); - desc.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); - let mut font = Dict::new(); - font.set(b"Subtype".to_vec(), Object::name("Type0")); - font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK")); - font.set(b"Encoding".to_vec(), Object::name("Identity-H")); - font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); - font.set(b"ToUnicode".to_vec(), Object::Ref(tu)); + let mut font = thai_font(&mut doc, &thai_ordered_program(), data); + let tu = font.get(b"ToUnicode").and_then(Object::as_ref).unwrap(); // The corrupt range is declared before explicit anchors in the actual exporter. let data = String::from_utf8_lossy(data) .replace("18 beginbfrange", "1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange") diff --git a/crates/fonts/src/source_shaping.rs b/crates/fonts/src/source_shaping.rs index 6976bd8a0e..b1043ad994 100644 --- a/crates/fonts/src/source_shaping.rs +++ b/crates/fonts/src/source_shaping.rs @@ -22,11 +22,33 @@ pub fn required(text: &str) -> bool { .any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069)) } -/// Whether a Thai source program retains its mark-positioning table. +/// Whether a Thai source program retains its mark positioning. /// Character coverage alone does not show that stacked vowels and tones can be laid out. /// OpenType Thai requires `mark`/`mkmk` (GPOS); `ccmp` substitution (GSUB) is optional. pub fn has_thai_layout(bytes: &[u8]) -> bool { - FontRef::new(bytes).is_ok_and(|f| f.gpos().is_ok()) + FontRef::new(bytes).is_ok_and(|f| positions_thai_marks(&f)) +} + +/// Whether the GPOS features the shaper uses for Thai include `mark` or `mkmk`. The selection is +/// HarfRust's, through the same read-fonts calls: the `thai` script, else `DFLT`, `dflt` or +/// `latn` (`ScriptList::select`); then, given no language, a language system tagged `dflt`, +/// else the script's default one. Marks under a script or language it doesn't select don't +/// count, nor does a GPOS with only kerning. +fn positions_thai_marks(face: &FontRef) -> bool { + use skrifa::Tag; + let Ok(gpos) = face.gpos() else { return false }; + let (Ok(scripts), Ok(features)) = (gpos.script_list(), gpos.feature_list()) else { return false }; + let records = features.feature_records(); + let marks = |index: u16| records.get(usize::from(index)).is_some_and(|r| r.feature_tag() == *b"mark" || r.feature_tag() == *b"mkmk"); + let Some(selected) = scripts.select(&[Tag::new(b"thai")]) else { return false }; + let Ok(script) = scripts.get(selected.index) else { return false }; + let language = match script.lang_sys_index_for_tag(Tag::new(b"dflt")) { + Some(index) => script.lang_sys(index).ok().map(|l| l.element), + None => script.default_lang_sys().transpose().ok().flatten(), + }; + let Some(language) = language else { return false }; + language.feature_indices().iter().any(|i| marks(i.get())) + || (language.required_feature_index() != 0xFFFF && marks(language.required_feature_index())) } pub fn shape(bytes: &[u8], text: &str) -> Result { @@ -46,8 +68,8 @@ fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result Result usize { + let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let record = (0..count).map(|i| 12 + i * 16).find(|&r| &bytes[r..r + 4] == b"GPOS").unwrap(); + u32::from_be_bytes(bytes[record + 8..record + 12].try_into().unwrap()) as usize + } + + fn u16_at(bytes: &[u8], at: usize) -> usize { + u16::from_be_bytes([bytes[at], bytes[at + 1]]) as usize + } + + /// Where the GPOS ScriptList starts. + fn script_list(bytes: &[u8]) -> usize { + gpos(bytes) + u16_at(bytes, gpos(bytes) + 4) + } + + /// Where script `tag`'s table starts. + fn script(bytes: &[u8], tag: &[u8; 4]) -> usize { + let list = script_list(bytes); + let record = (0..u16_at(bytes, list)).map(|i| list + 2 + i * 6).find(|&r| &bytes[r..r + 4] == tag).unwrap(); + list + u16_at(bytes, record + 4) + } + + /// Retag script `from` as `to`, keeping the records sorted by tag as the shaper's binary + /// search needs. + fn retag_script(bytes: &mut [u8], from: &[u8; 4], to: &[u8; 4]) { + let list = script_list(bytes); + let count = u16_at(bytes, list); + let mut records: Vec<[u8; 6]> = (0..count).map(|i| bytes[list + 2 + i * 6..list + 8 + i * 6].try_into().unwrap()).collect(); + records.iter_mut().find(|r| &r[..4] == from).unwrap()[..4].copy_from_slice(to); + records.sort_by(|a, b| a[..4].cmp(&b[..4])); + for (i, record) in records.iter().enumerate() { + bytes[list + 2 + i * 6..list + 8 + i * 6].copy_from_slice(record); + } + } + + /// A shaper uses a font's `thai` script when it has one: marks the font registers only for + /// Latin don't position Thai. + #[test] + fn thai_layout_counts_only_the_script_the_shaper_selects() { + let mut bytes = crate::script::INTER_REGULAR.to_vec(); + // Inter's DFLT becomes a `thai` script whose default language system has no features; + // its latn script keeps `mark` and `mkmk`. + retag_script(&mut bytes, b"DFLT", b"thai"); + let thai = script(&bytes, b"thai"); + let language = thai + u16_at(&bytes, thai); + assert_ne!(language, thai, "a default language system"); + bytes[language + 2..language + 6].copy_from_slice(&[0xFF, 0xFF, 0, 0]); + assert!(!has_thai_layout(&bytes), "the latn script's marks are not the thai script's"); + } + + /// Given no language, the shaper takes a language system tagged `dflt` before the script's + /// default one, in both directions. + #[test] + fn thai_layout_follows_a_language_system_tagged_dflt() { + // Without DFLT the shaper falls back to latn, whose default language system has marks and + // is shared by its CAT, MOL and ROM records. Cyrillic's default one has only `kern`. + let mut base = crate::script::INTER_REGULAR.to_vec(); + retag_script(&mut base, b"DFLT", b"zzzz"); + assert!(has_thai_layout(&base)); + let latn = script(&base, b"latn"); + let kern_only = script(&base, b"cyrl") + u16_at(&base, script(&base, b"cyrl")); + let kern_only = u16::try_from(kern_only - latn).unwrap().to_be_bytes(); + let rom = (0..u16_at(&base, latn + 2)).map(|j| latn + 4 + j * 6).find(|&r| &base[r..r + 4] == b"ROM ").unwrap(); + // ROM becomes `dflt` (still sorted last) and has only kerning: it wins over the default. + let mut named_without = base.clone(); + named_without[rom..rom + 4].copy_from_slice(b"dflt"); + named_without[rom + 4..rom + 6].copy_from_slice(&kern_only); + assert!(!has_thai_layout(&named_without), "the dflt language system has no marks"); + // The default has only kerning and the `dflt` one keeps the marks: it wins again. + let mut default_without = base.clone(); + default_without[rom..rom + 4].copy_from_slice(b"dflt"); + default_without[latn..latn + 2].copy_from_slice(&kern_only); + assert!(has_thai_layout(&default_without), "the dflt language system has the marks"); + } + + /// A GPOS table alone is not mark positioning: with its `mark` and `mkmk` features renamed, + /// as in a kerning-only subset, a face can't stack Thai vowels and tones. + #[test] + fn thai_layout_needs_mark_positioning_features() { + let inter = crate::script::INTER_REGULAR; + assert!(has_thai_layout(inter), "Inter positions marks under its default script"); + let mut bytes = inter.to_vec(); + let table = gpos(&bytes); + let features = table + u16_at(&bytes, table + 6); + let mut renamed = 0; + for i in 0..u16_at(&bytes, features) { + let tag = features + 2 + i * 6; + if matches!(&bytes[tag..tag + 4], b"mark" | b"mkmk") { + bytes[tag..tag + 4].copy_from_slice(b"kern"); + renamed += 1; + } + } + assert!(renamed > 0); + assert!(FontRef::new(&bytes).unwrap().gpos().is_ok(), "the GPOS table still parses"); + assert!(!has_thai_layout(&bytes)); + let error = shape(&bytes, "กิ่").unwrap_err(); + assert!(error.contains("mark positioning"), "{error}"); + } + #[test] fn thai_positioning_does_not_require_optional_gsub() { - let Some(face) = crate::document_thai_font() else { return }; + let Some(face) = crate::document_thai_font() else { + eprintln!("skipping thai_positioning_does_not_require_optional_gsub: built without craft-fonts (set CRAFT_FONTS_DIR to run it)"); + return; + }; let mut bytes = face.bytes.to_vec(); // Keep the actual GPOS anchors and outlines while removing optional substitutions. // Table payloads stay at their original offsets; no font fixture is committed. diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index a8966ff207..b7716cf4d9 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -5,11 +5,16 @@ page in an undoable **Prepare grouped text** step. The group's original operator 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. +Preparation refuses knockout/blended groups, groups that blend in a colour space the page +group does not also name (including DeviceRGB on a page that names none), tagged forms, +recursive forms, unfinished paths and unreadable content. Forms that draw only images or +vector artwork are left grouped. Decoding is capped at 16 MiB, 64 placements and eight +expansion levels per page; text grouped deeper is reported. Unsupported groups remain intact: +`text_prepare` lists each with its reason, and the app records them in the repair log when it +prepares other groups on the page. When no group on a page can be prepared, the app says +nothing; the text stays grouped. A narrowly fingerprinted legacy THSarabunPSK exporter map +can also be repaired in copied page fonts, only when every glyph the subset kept advances as +its assumed Thai character does; the repair log records both preparation and 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; @@ -59,7 +64,9 @@ add a font binary or change the release font pin. Fonts shared by releases belon 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 +face can supply Thai and punctuation glyphs, including a Thai run whose clusters are split +across subsets, and a subset whose glyph for a character is empty is skipped; 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. @@ -68,14 +75,25 @@ complete original face or an explicit replacement. Cross-family replacement requ 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. +subset can still be completed by that same bundled family when it is Regular and upright; a +subset its name or descriptor marks bold or italic needs an explicit replacement. 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 +shaping uses the source's GPOS mark positioning (a `mark` or `mkmk` feature in the script and language system the shaper +selects for Thai, not just a GPOS table); 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. +Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS; their +alternates are used only when each kept one has a mark's zero advance (or, for the two +consonant forms, an advance). A THSarabunPSK program with its own Thai mark +positioning, such as a complete face loaded with Load font, is shaped by that layout instead, +still in its own codes. One without it takes the legacy path only when every Thai glyph it keeps +is where the legacy glyph order puts it, and is otherwise shaped as an ordinary source font. A +subset needs only the glyphs its new text uses. When no subset can show the new text in its +existing codes, a complete program of the same face and style (one added with Load font, CFF +included) shapes the whole span with its own layout, Latin rows too, rather than the edit being +refused; a Regular program never completes text the source marks bold or italic. Mixed contextual style runs and dictionary word segmentation remain limited. No font binary or release pin changes are needed. diff --git a/docs/roadmap.md b/docs/roadmap.md index 28afeea246..2b42b9ba9c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # 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-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; 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) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 54aa9561b5..b752fe76b5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1895,8 +1895,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] 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" +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "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 (including RGB-blended groups on a page that names no blending space) remain intact and are reported by text_prepare (the app records them in the repair log only when it prepares other groups on the page, and says nothing when it can prepare none), as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded. Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), 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"