From ace4321b81323f5f89d138ff7e35e262db5d72d5 Mon Sep 17 00:00:00 2001 From: dekkeng Date: Fri, 16 Oct 2026 15:36:30 +0700 Subject: [PATCH] M7: fix matching Thai subset edits and stacked marks --- ROADMAP.md | 5 + crates/automation/tests/automation.rs | 54 ++++++++++ crates/edit/src/added.rs | 52 ++++++++++ crates/edit/src/tests.rs | 30 ++++++ crates/edit/src/text.rs | 10 +- crates/edit/src/text_regions.rs | 124 ++++++++++++++++++++--- crates/fonts/src/shaping.rs | 23 ++++- crates/fonts/src/source_shaping.rs | 38 ++++++- crates/ui-egui/src/edit_text_ui.rs | 137 +++++++++++++++++++++++++- docs/editing-existing-text.md | 16 +++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 4 +- 12 files changed, 474 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..a3633859ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,8 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; estimates unchanged) + > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) @@ -157,6 +159,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-16 (M7, Thai editing):** Existing Noto Sans Thai paragraphs use matching shaped outlines when their subset cannot shape the draft, avoiding variable-font embedding errors. The inline editor prefers complete matching layout tables for stacked vowels/tone marks. Regression coverage includes long rows, repeated edits, layout, undo/redo and save/reopen rendering; other Thai families and mixed contextual styles remain limited. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. @@ -495,6 +499,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M7: matching Thai subset completion avoids variable-font embedding; complete matching preview tables preserve stacked marks. Other Thai families and mixed contextual styles remain limited; estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..ba3af5913d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5841,6 +5841,60 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +#[test] +fn existing_thai_matching_face_edit_layout_undo_save_reopen_and_render() { + use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full}; + let dir = workdir("thai-existing"); + let mut source = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + // Synthetic source encoding lacks Thai; the approved matching family supplies the missing + // contextual glyphs. No fixture/font bytes are checked in. + source + .update_dict(ObjRef::new(3, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + }) + .unwrap(); + std::fs::write(dir.join("thai-source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "thai-source.pdf"}))["doc"].as_u64().unwrap(); + let before = page_text(&mut a, doc); + let text = "น้ำ ผู้ใช้ ๑๒๓"; + let args = json!({"doc": doc, "page": 1, "paragraph": 1, "text": text, "size": 12}); + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(a.call("text_edit", &args).is_err()); + assert_eq!(page_text(&mut a, doc), before); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false); + return; + } + ok(&mut a, "text_edit_layout", args.clone()); + assert_eq!(page_text(&mut a, doc), before, "layout is read-only"); + ok(&mut a, "text_edit", args); + assert!(page_text(&mut a, doc)[0].contains(text)); + ok(&mut a, "edit_undo", json!({"doc": doc})); + assert_eq!(page_text(&mut a, doc), before); + ok(&mut a, "edit_redo", json!({"doc": doc})); + assert!(page_text(&mut a, doc)[0].contains(text)); + let render = |a: &mut Automation, doc| { + a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 144})) + .unwrap() + .into_iter() + .find_map(|c| match c { + Content::Png { data, .. } => Some(data), + _ => None, + }) + .unwrap() + }; + let image = render(&mut a, doc); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "thai-edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(render(&mut a, reopened), image); + assert!(page_text(&mut a, reopened)[0].contains(text)); + ok(&mut a, "text_edit", json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "ปู่ น้ำ ๔๕๖"})); + assert!(page_text(&mut a, reopened)[0].contains("ปู่ น้ำ ๔๕๖")); + let unchanged = page_text(&mut a, reopened); + assert!(a.call("text_edit", &json!({"doc": reopened, "page": 1, "paragraph": 1, "text": "น้ำ 😀"})).is_err()); + assert_eq!(page_text(&mut a, reopened), unchanged, "an unavailable glyph leaves the edit intact"); +} + #[test] fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { let dir = workdir("hebrew-content"); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d246df725b..ddc8cf3287 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -737,6 +737,58 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash doc.add(Object::Dict(font)) } +/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline +/// writer instead of trying to embed the matching craft-fonts variable program as a static face. +pub(crate) fn thai_glyph_resources( + doc: &mut Document, + fonts: &mut Dict, + shaped: &pdfcraft_fonts::source_shaping::ShapedRun, +) -> Result)>, EditError> { + if shaped.glyphs.len() > 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + let mut outlines = HashMap::new(); + let mut clusters = Vec::new(); + let mut index = HashMap::new(); + let mut indices = Vec::new(); + for glyph in &shaped.glyphs { + let id = glyph.id.to_u32(); + let key = (ShapedScript::Thai, id); + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) { + entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?); + } + let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| { + let at = clusters.len(); + let width = outlines.get(&key).map_or(0.0, |g| g.width); + clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() }); + at + }); + indices.push(at); + } + // Validate all outlines before adding resources. Type 3 character codes are single bytes; + // shard long rows and return the appropriate resource/code for every positioned glyph. + let mut names = Vec::new(); + for chunk in clusters.chunks(GLYPHS_PER_FONT) { + let refs: Vec<_> = chunk.iter().collect(); + let font = type3_shaped(doc, &refs, &outlines); + doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?; + let mut name = format!("PCThai{}", font.num); + while fonts.contains(name.as_bytes()) { + name.push('_'); + } + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + names.push(name); + } + indices + .into_iter() + .map(|at| { + let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; + let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; + Ok((name.clone(), vec![code])) + }) + .collect() +} + /// Content and resources for an item; `view` maps display space to user space. `taken` holds the /// page's font names. `existing`: the item is being replaced, so a build without the Arabic or /// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df8..e70a0b9939 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2813,6 +2813,36 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() { assert!(arabic_font_names(&doc, 0).is_empty()); } +#[test] +fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() { + let Some(_) = pdfcraft_fonts::document_thai_font() else { return }; + // Synthetic source subset: only one Thai letter is encoded. No binary fixture or font + // is committed; the complete, openly licensed matching face is the optional build input. + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + let neighbour = text::text_lines(&doc, 0).unwrap()[1].clone(); + // More than 240 distinct logical clusters requires several single-byte Type 3 resources. + let long: String = + "กขคงจฉชซญดตถทนบปผฝพฟมยรลวศษสหฬอฮ".chars().flat_map(|c| ["ิ่", "ิ้", "ี่", "ี้", "ึ่", "ึ้", "ื่", "ื้"].map(move |marks| format!("{c}{marks}"))).collect(); + let draft = format!("{long} ผู้ใช้ ปู่ ๑๒๓"); + let style = text::BlockStyle { width: Some(14_400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [50.0, 700.0]); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, neighbour.text); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), neighbour.baseline()); + let next = "Hello ผู้ใช้ น้ำ ๔๕๖"; + text::rewrite_block(&mut doc, 0, 0, Some(next), &text::BlockStyle::default()).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next); +} + #[test] fn group_moves_preserve_glyph_bytes_and_unselected_text_state() { for content in [ diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a39..53232b7237 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -48,7 +48,7 @@ pub struct TextLine { pub decodable: bool, /// Whether the font requires visual-order Indic text. pub visual_order: bool, - /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). + /// The generated fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). @@ -506,7 +506,7 @@ struct Ts { /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). fill: Vec, font: Option<(Vec, Rc)>, - /// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]). + /// The generated fallback face the font subsets, when the font is one (see [`is_type3_fallback`]). face: Option, size: f64, char_spacing: f64, @@ -1304,14 +1304,14 @@ const JAPANESE_FONT_NAME: &str = "PCJapanese"; /// The `/Name` of the Type 3 fonts generated for Hebrew text. const HEBREW_FONT_NAME: &str = "PCHebrew"; -/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than +/// Whether `font` is one of those generated fonts (Japanese, Hebrew or Thai): a replacement longer than /// one of them is split across several, which read back as one line (see [`same_font`]). fn is_type3_fallback(font: &Dict) -> bool { font.name(b"Subtype") == Some(b"Type3") - && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) + && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == b"PCThai") } -/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if +/// Whether a run shown in `font` (a resource name, and the generated fallback face it subsets, if /// any) continues `line`: the same font resource, or another subset of the same face. fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool { line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face) diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..7e96899e08 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -168,13 +168,65 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { blocks } +/// Whether the approved Thai face completes this source family, 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 }; + 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) + && !fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)).and_then(|f| pdfcraft_fonts::pdf::embedded_sfnt(doc, &f)).is_some_and( + |bytes| { + pdfcraft_fonts::embedding::validate(&bytes).is_ok() + && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) + && pdfcraft_fonts::embedding::covers(&bytes, span) + && span.split('\n').all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + }, + ) +} + +/// Use the same Thai script selection and mark/cluster positioning as Add Text, including +/// rows beginning with Latin or digits (guessing from their first character would select Latin). +fn shape_thai_row(text: &str) -> Result { + if text.len() > 262_144 || text.contains(['\n', '\r']) { + return Err(EditError::Invalid("shape one bounded text row at a time".into())); + } + let clusters = + pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?; + let mut glyphs = Vec::new(); + let mut advance = 0.0; + for cluster in clusters { + for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() { + if glyphs.len() >= 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { + id: id.into(), + position: [advance + x, y], + text: if i == 0 { cluster.text.clone() } else { String::new() }, + }); + } + advance += cluster.advance; + } + if !advance.is_finite() { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance }) +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, fonts: &Dict, + outlined_thai: &std::collections::HashSet, name: &str, text: &str, ) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { + if outlined_thai.contains(name) { + let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| EditError::Invalid("the matching Thai face is unavailable".into()))?; + let shaped = shape_thai_row(text)?; + return Ok(Some((face.bytes.to_vec(), shaped, face.family.to_owned()))); + } if !pdfcraft_fonts::source_shaping::required(text) { return Ok(None); } @@ -393,15 +445,40 @@ pub(super) fn rewrite_styled_block( let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); let mut full_faces: HashMap = HashMap::new(); let mut programs: HashMap>>> = HashMap::new(); + let mut outlined_thai = std::collections::HashSet::new(); for (range, index) in styled_spans { let source = lines.get(index).unwrap_or(first); - for ch in draft.get(range).unwrap_or_default().chars() { + let span = draft.get(range).unwrap_or_default(); + // Only complete the same Thai family, only when no face was explicitly selected, + // and keep a usable embedded source program first. Never export interface fallback + // fonts or change unrelated scripts' static-font embedding rules. + let use_thai_outlines = style.resource.is_none() + && overrides.get(&index).and_then(|r| r.resource.as_ref()).is_none() + && matching_thai_outline(doc, &fonts, source, span); + let outline_name = if use_thai_outlines { + // A draft-only name prevents this choice from affecting another style span that + // explicitly selects the original resource. It is never saved into the PDF. + let mut name = format!("PCOutlineThai{index}"); + while metrics.contains_key(&name) { + name.push('_'); + } + if let Some(m) = metrics.get(&source.font).cloned() { + metrics.insert(name.clone(), m); + } + outlined_thai.insert(name.clone()); + Some(name) + } else { + None + }; + for ch in span.chars() { if ch == '\n' { chars.push((ch, index, source.font.clone())); continue; } let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { + let name = if let Some(name) = &outline_name { + name.clone() + } else if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); } @@ -538,7 +615,7 @@ pub(super) fn rewrite_styled_block( } let segment = items.get(at..end).unwrap_or_default(); let string: String = segment.iter().map(|v| v.0).collect(); - if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + if let Some((_, shaped, _)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? { let source = lines.get(item.1).unwrap_or(first); let scale = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); @@ -654,7 +731,7 @@ pub(super) fn rewrite_styled_block( } segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { + if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &outlined_thai, &item.2, &string)? { shaped_runs.insert((row_index, at), (bytes, shaped, family)); } at = end; @@ -662,9 +739,20 @@ pub(super) fn rewrite_styled_block( } let mut glyph_resources = HashMap::new(); for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); + let name = rows.get(key.0).and_then(|row| row.get(key.1)).map(|c| &c.2); + let resources = if name.is_some_and(|n| outlined_thai.contains(n)) { + crate::added::thai_glyph_resources(doc, &mut fonts, shaped)? + } else { + let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + (0..shaped.glyphs.len()) + .map(|i| { + let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + Ok((resource.clone(), code.to_be_bytes().to_vec())) + }) + .collect::, EditError>>()? + }; + glyph_resources.insert(*key, resources); } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; @@ -748,29 +836,41 @@ pub(super) fn rewrite_styled_block( tm[5] += ay; let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { - let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); - state.font = Some((resource.clone(), size_of(source, item.1))); // Absolute glyph matrices preserve marks and contextual positions; font-program // codes are separate from Unicode and ToUnicode/ActualText keep logical text. let size = size_of(source, item.1); let mut extra = 0.0; + let thai = outlined_thai.contains(&item.2); + let mut cluster_spacing = 0.0; for (i, glyph) in shaped.glyphs.iter().enumerate() { + if thai && !glyph.text.is_empty() { + // Thai cluster text belongs to its first glyph. Defer its spacing + // until the next cluster so the vowel/tone glyphs stay on their base. + extra += cluster_spacing; + cluster_spacing = + state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64; + } let mut matrix = tm; let x = (glyph.position[0] * size + extra) * state.scale; let y = glyph.position[1] * size; matrix[4] += matrix[0] * x + matrix[2] * y; matrix[5] += matrix[1] * x + matrix[3] * y; - let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + let (resource, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?; + state.font = Some((resource.clone(), size)); block_ops.push(Op::new("BT", vec![])); block_ops.extend(state.ops()); block_ops.push(Op::new("Tm", matrix.map(n).to_vec())); - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))])); + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.clone()))])); block_ops.push(Op::new("ET", vec![])); - extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64; + if !thai { + extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64; + } } + extra += cluster_spacing; block_ops.push(Op::new("EMC", vec![])); segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; } else { diff --git a/crates/fonts/src/shaping.rs b/crates/fonts/src/shaping.rs index f0bd118c15..ce7a35a3ec 100644 --- a/crates/fonts/src/shaping.rs +++ b/crates/fonts/src/shaping.rs @@ -238,6 +238,27 @@ mod tests { mod thai_tests { use super::*; + #[test] + fn thai_vowels_and_tones_occupy_separate_vertical_bands() { + if crate::document_thai_font().is_none() { + return; + } + for text in ["กิ่", "กี้", "กึ้", "น้ำ", "น่ำ", "น้ํา", "นํ้า"] { + let mut marks = Vec::new(); + for cluster in shape_thai(text).unwrap() { + for (id, [_, y]) in cluster.glyphs { + let g = shaped_glyph(ShapedScript::Thai, id).unwrap(); + if g.width == 0.0 && !g.contours.is_empty() { + marks.push([g.bbox[1] + y, g.bbox[3] + y]); + } + } + } + assert_eq!(marks.len(), 2, "{text}: vowel/Sara Am dot and tone"); + marks.sort_by(|a, b| a[0].total_cmp(&b[0])); + assert!(marks[0][1] <= marks[1][0] + 0.005, "{text}: lower and upper marks overlap: {marks:?}"); + } + } + #[test] fn thai_clusters_preserve_sara_am_and_stacked_marks() { if crate::document_thai_font().is_none() { @@ -245,7 +266,7 @@ mod thai_tests { assert!(!thai_has('ก')); return; } - for text in ["น้ำ", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""] + for text in ["น้ำ", "น้ํา", "นํ้า", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""] { let clusters = shape_thai(text).unwrap(); assert_eq!(clusters.iter().map(|c| c.text.as_str()).collect::(), text); diff --git a/crates/fonts/src/source_shaping.rs b/crates/fonts/src/source_shaping.rs index b6b28ca692..218ac03c84 100644 --- a/crates/fonts/src/source_shaping.rs +++ b/crates/fonts/src/source_shaping.rs @@ -22,6 +22,13 @@ 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. +/// 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()) +} + pub fn shape(bytes: &[u8], text: &str) -> Result { if text.len() > 262_144 || text.contains(['\n', '\r']) { return Err("shape one bounded text row at a time".into()); @@ -30,7 +37,12 @@ pub fn shape(bytes: &[u8], text: &str) -> Result { return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 }); } let face = FontRef::new(bytes).map_err(|e| e.to_string())?; - if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() { + if text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) && face.gpos().is_err() { + return Err("the source subset has no Thai mark-positioning table (GPOS); select its full font".into()); + } + if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0dff | 0x0e80..=0x0fff | 0x1780..=0x17ff)) + && face.gsub().is_err() + { return Err("the source subset has no contextual substitution tables; select its full font".into()); } let data = ShaperData::new(&face); @@ -74,3 +86,27 @@ pub fn shape(bytes: &[u8], text: &str) -> Result { } Ok(ShapedRun { glyphs, advance: x }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thai_positioning_does_not_require_optional_gsub() { + let Some(face) = crate::document_thai_font() else { 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. + let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let tables: Vec<_> = bytes[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB").map(|t| t.to_vec()).collect(); + bytes[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes()); + for (i, table) in tables.iter().enumerate() { + bytes[12 + i * 16..28 + i * 16].copy_from_slice(table); + } + assert!(FontRef::new(&bytes).unwrap().gsub().is_err()); + assert!(has_thai_layout(&bytes)); + let shaped = shape(&bytes, "กิ่ น้ำ").unwrap(); + assert!(shaped.advance > 0.0); + assert!(shaped.glyphs.iter().all(|g| g.position.iter().all(|p| p.is_finite()))); + } +} diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d..db68264bc6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1129,6 +1129,7 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D } for program in &programs { let Some(name) = families.get(&program.resource) else { continue }; + let complete_thai = thai_subset_needs_layout(&program.bytes, &program.face); // Word-created PDFs often split one face into several subsets. Use all of the // document's matching subsets before falling back for newly inserted characters. let mut stack = vec![name.clone()]; @@ -1136,11 +1137,24 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D programs .iter() .filter(|p| { - pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face) - && p.resource != program.resource + p.resource != program.resource + && (pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face) + || complete_thai && p.resource.starts_with("PCApproved") && matching_thai_face(&p.face)) }) .filter_map(|p| families.get(&p.resource).cloned()), ); + // A Thai subset can contain every typed character while lacking the layout tables + // that raise tone marks above vowels/Sara Am. It would win ordinary glyph fallback + // and keep those marks on one level. Prefer only its approved complete matching face; + // a complete source with usable tables, and every other family, keeps the existing order. + if complete_thai { + let approved: Vec<_> = programs + .iter() + .filter(|p| p.resource.starts_with("PCApproved") && matching_thai_face(&p.face)) + .filter_map(|p| families.get(&p.resource)) + .collect(); + stack.sort_by_key(|name| !approved.contains(&name)); + } if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) { stack.extend(fallback.iter().cloned()); } @@ -1152,6 +1166,28 @@ pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::D families } +fn thai_subset_needs_layout(bytes: &[u8], face: &str) -> bool { + use skrifa::MetadataProvider; + if !matching_thai_face(face) { + return false; + } + let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false }; + let (Ok(source), Ok(full)) = (skrifa::FontRef::new(bytes), skrifa::FontRef::new(thai.bytes)) else { return true }; + // New marks must be shaped with their base in one face, not picked individually from + // the fallback stack. A complete source keeps priority; an incomplete Thai cmap does not. + !pdfcraft_fonts::source_shaping::has_thai_layout(bytes) + || !full + .charmap() + .mappings() + .filter(|(cp, _)| matches!(*cp, 0x0e00..=0x0e7f)) + .all(|(cp, _)| source.charmap().map(cp).is_some_and(|g| g.to_u32() != 0)) +} + +fn matching_thai_face(face: &str) -> bool { + let Some(thai) = pdfcraft_fonts::document_thai_font() else { return false }; + pdfcraft_fonts::embedding::matching_faces(face).iter().any(|f| std::ptr::eq(*f, thai)) +} + fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob { let mut job = egui::text::LayoutJob::default(); // An unchanged PDF row already has an authoritative line break. Do not rewrap it @@ -1444,6 +1480,103 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + #[test] + fn thai_editor_keeps_complete_sources_but_completes_new_marks_as_one_face() { + use skrifa::MetadataProvider; + let Some(thai) = pdfcraft_fonts::document_thai_font() else { return }; + assert!(!thai_subset_needs_layout(thai.bytes, "NotoSansThai-Regular")); + // Keep valid GPOS, but remove mai tho from the Unicode map. Per-character fallback + // would put this newly typed tone in a different shaping run from its source base. + let full = skrifa::FontRef::new(thai.bytes).unwrap(); + let mappings: Vec<_> = full.charmap().mappings().filter(|(cp, _)| *cp != 0x0e49).collect(); + let mut cmap = vec![0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12]; + cmap.extend_from_slice(&12u16.to_be_bytes()); + cmap.extend_from_slice(&0u16.to_be_bytes()); + cmap.extend_from_slice(&u32::try_from(16 + mappings.len() * 12).unwrap().to_be_bytes()); + cmap.extend_from_slice(&0u32.to_be_bytes()); + cmap.extend_from_slice(&u32::try_from(mappings.len()).unwrap().to_be_bytes()); + for (cp, id) in mappings { + for v in [cp, cp, id.to_u32()] { + cmap.extend_from_slice(&v.to_be_bytes()); + } + } + let mut bytes = thai.bytes.to_vec(); + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } + let offset = u32::try_from(bytes.len()).unwrap(); + let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let record = bytes[12..12 + count * 16].as_chunks_mut::<16>().0.iter_mut().find(|t| &t[..4] == b"cmap").unwrap(); + record[8..12].copy_from_slice(&offset.to_be_bytes()); + record[12..16].copy_from_slice(&u32::try_from(cmap.len()).unwrap().to_be_bytes()); + bytes.extend(cmap); + assert!(pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)); + assert!(!pdfcraft_fonts::embedding::covers(&bytes, "้")); + assert!(thai_subset_needs_layout(&bytes, "NotoSansThai-Regular")); + assert!(!thai_subset_needs_layout(&bytes, "UnrelatedFamily")); + } + + #[test] + fn thai_editor_uses_complete_matching_layout_for_stacked_marks() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let Some(thai) = pdfcraft_fonts::document_thai_font() else { return }; + // Synthetic subset: keep all outlines/cmap, remove only GSUB/GPOS. Existing characters + // still win ordinary glyph fallback, but the two upper marks no longer stack correctly. + let mut subset = thai.bytes.to_vec(); + let count = u16::from_be_bytes([subset[4], subset[5]]) as usize; + let tables: Vec<_> = + subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB" && &t[..4] != b"GPOS").map(|t| t.to_vec()).collect(); + subset[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes()); + for (i, table) in tables.iter().enumerate() { + subset[12 + i * 16..28 + i * 16].copy_from_slice(table); + } + let (mut app, _) = preview_app(); + let mut pdf = Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let resource = app.session.get(app.views[0].id).unwrap().text_lines(0)[0].font.clone(); + let program = pdf.add(Object::Stream(Stream::from_raw(Dict::new(), subset))); + let descriptor = + Dict::from_iter([(b"FontFile2".to_vec(), Object::Ref(program)), (b"FontName".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular"))]); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("TrueType")), + (b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")), + (b"FontDescriptor".to_vec(), Object::Dict(descriptor)), + ]); + let mut resources = pdf.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let mut fonts = pdf.dict(resources.get(b"Font").unwrap()).unwrap(); + fonts.set(resource.as_bytes().to_vec(), Object::Dict(font)); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + pdf.update_dict(page.obj, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + app.open_bytes("thai-preview.pdf", None, write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap(); + let doc = app.session.get(app.views.last().unwrap().id).unwrap(); + let ctx = egui::Context::default(); + ctx.begin_pass(Default::default()); + let families = install_source_fonts(&ctx, doc, 0); + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(Default::default()); + let name = families.get(&resource).unwrap(); + let family = FontFamily::Name(name.clone().into()); + let definitions = ctx.fonts(|f| f.definitions().clone()); + let stack = definitions.families.get(&family).unwrap(); + let preferred = stack.first().unwrap(); + assert!(definitions.font_data.get(preferred).unwrap().font.as_ref() == thai.bytes, "the editor must shape with the complete matching face"); + let text = "กิ่ น้ำ น่ำ กี้ กึ้"; + let actual = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, family), Color32::BLACK)); + // Compare the mark offsets against the complete face alone, independently of the + // source-font stack; this also catches falling back one character at a time. + let expected_family = FontFamily::Name("thai-complete-only".into()); + let mut expected_definitions = definitions.clone(); + expected_definitions.families.insert(expected_family.clone(), vec![preferred.clone()]); + ctx.set_fonts(expected_definitions); + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(Default::default()); + let expected = ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), FontId::new(48.0, expected_family), Color32::BLACK)); + let offsets = |galley: &egui::Galley| galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect.offset)).collect::>(); + assert_eq!(offsets(&actual), offsets(&expected)); + ctx.end_pass().textures_delta.clear(); + } + /// An app with one text document open, and an editor on its first paragraph. fn preview_app() -> (crate::PdfCraftApp, LineEditor) { let mut app = crate::PdfCraftApp::new(); diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index e8bb03b9a4..93737dc766 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -37,6 +37,22 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +For an existing Noto Sans Thai paragraph whose subset cannot shape the draft, the matching +approved Thai face supplies positioned outlines, using the same Thai shaping as Add Text. +It does not attempt to embed the shared variable font as a static program. Stacked vowels, +tone marks and Sara Am keep their OpenType positions, including after save/reopen and repeated +edits; logical text remains available for copying and searching. The inline editor also prefers +that complete matching face when the subset lacks GPOS mark-positioning tables or Thai characters, +so newly typed marks stay in the same shaping run as their base. A usable complete source program keeps precedence. +This requires the optional Thai build input and applies to matching-family paragraph edits; +other Thai families still need their complete source font, and mixed contextual styles retain +the restrictions below. No font asset or release pin is changed. +Thai shaping reuses the existing [HarfRust](https://github.com/harfbuzz/harfrust) shaper and +the font's `mark`/`mkmk` GPOS anchors from the +[OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai). +Vertical mark offsets are not guessed or hardcoded; GSUB `ccmp` substitution is optional for +Thai and its absence alone no longer refuses a source face with usable positioning. + Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any two characters. A row never begins with closing punctuation, a closing bracket or quote, a small kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..7de4d8ea99 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (M7: matching Thai subset completion and stacked-mark editor layout; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -118,7 +120,7 @@ total matches the parity estimate. | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done since: matching Noto Sans Thai subset completion uses positioned outlines; the inline editor uses complete matching layout tables for stacked marks. Other Thai families still need the complete source font. Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -179,6 +181,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | M7: existing Noto Sans Thai paragraph edits reuse shaped outlines instead of embedding the matching variable font; inline subset previews prefer the complete matching layout tables. Other Thai families and mixed contextual runs remain limited; estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94f..5f660bfe7a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1866,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); 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. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/automation/tests/automation.rs::existing_thai_matching_face_edit_layout_undo_save_reopen_and_render", "crates/ui-egui/src/edit_text_ui.rs::thai_editor_uses_complete_matching_layout_for_stacked_marks", "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/editing.rs::editing_existing_text_in_place", "crates/automation/tests/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/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/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/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/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/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/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +notes = "Matching Noto Sans Thai paragraph subsets use shaped outlines without embedding the variable face; editor previews prefer complete matching Thai layout tables for stacked marks. Other Thai families require their full source face; mixed contextual styles 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); 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. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow"