From 77cebccda21e7bc7dccc2d8f5e66f81865656688 Mon Sep 17 00:00:00 2001 From: xullul <32987931+xullul@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:01:31 +0700 Subject: [PATCH 01/21] M7: adapt embedded Thai source fonts without replacing their programs Add: - Recover editor-only Unicode cmaps for Identity-H TrueType subsets and shape verified THSarabunPSK positional glyphs into their original PDF character codes. - Recognize narrowly fingerprinted damaged Thai exporter maps without guessing mappings for unrelated fonts; callers must record and isolate any repairs. Change: - Preserve the original PDF font programs and refuse missing contextual glyphs. - Allow modern Thai GPOS positioning without requiring optional GSUB, adapting that correction from dekkeng's PR #2811 at fba0a29a. Refs #3185 and #2142. Font/edit tests: 535 passed. Formatting, scoped strict Clippy, layers, assets, parity and the fonts/edit wasm check passed. --- crates/fonts/README.md | 7 + crates/fonts/src/pdf.rs | 89 +++++-- crates/fonts/src/pdf/tests.rs | 41 ++++ crates/fonts/src/pdf/thai.rs | 372 +++++++++++++++++++++++++++++ crates/fonts/src/source_shaping.rs | 53 +++- 5 files changed, 543 insertions(+), 19 deletions(-) create mode 100644 crates/fonts/src/pdf/thai.rs diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 096f1a263e..c462c428d4 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -86,3 +86,10 @@ Document font inputs use `embedding`: bounded static TrueType/OpenType-CFF valid exact program preservation, Unicode/CID/GID mapping and full-face subset completion. `shaping` uses the same program with harfrust and UAX #9; source UI fallback fonts are never exported. See [existing-text editing](../../docs/editing-existing-text.md). + +`pdf::embedded_sfnt_within` reconstructs a missing Unicode cmap for Identity-H TrueType +subsets from the PDF's ToUnicode and CID-to-glyph maps. Only the in-memory editor copy changes; +the PDF's font program stays intact. `pdf::LegacyThaiFont` shapes verified THSarabunPSK +positional glyphs into their original PDF codes, retaining the source face and weight. Missing +contextual glyphs are refused. `pdf::recovered_thai_map` recognizes narrowly fingerprinted +damaged exporter maps; callers must record repairs and apply them to page-owned font copies. diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 240b1c4d55..4fab8a0313 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -116,22 +116,23 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if face.charmap().mappings().next().is_some() { return Some(bytes); } - // Some PDF generators keep a Mac format-6 code→glyph cmap. egui accepts Unicode - // format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs; - // only this in-memory copy gets a new cmap, never the working PDF or a font asset. - let Ok(cmap) = face.cmap() else { continue }; + // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only + // cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged. let metrics = Metrics::from_dict(doc, font); - if metrics.composite { - continue; - } let mut mappings = std::collections::BTreeMap::new(); - for code in 0..=255 { - let Some(text) = metrics.unicode.text(code) else { continue }; - let Some(ch) = only_char(&text) else { continue }; - let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue }; - let glyph = glyph.to_u32(); - if glyph > 0 { - mappings.entry(u32::from(ch)).or_insert(glyph); + if metrics.composite { + if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { + mappings = map; + } + } else if let Ok(cmap) = face.cmap() { + for code in 0..=255 { + let Some(text) = metrics.unicode.text(code) else { continue }; + let Some(ch) = only_char(&text) else { continue }; + let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue }; + let glyph = glyph.to_u32(); + if glyph > 0 { + mappings.entry(u32::from(ch)).or_insert(glyph); + } } } if !mappings.is_empty() @@ -214,14 +215,14 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { } } -/// Replace an existing cmap directory entry with a bounded Unicode format-12 table. +/// Add or replace an editor-only cmap with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { if mappings.len() > 65536 { return None; } let count = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?) as usize; - if count > 256 { + if count == 0 || count > 256 { return None; } let mut entry = None; @@ -236,7 +237,10 @@ fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap head = Some(u32::from_be_bytes(record.get(8..12)?.try_into().ok()?) as usize); } } - let entry = entry?; + let (entry, head) = match entry { + Some(entry) => (entry, head), + None => insert_editor_cmap_slot(bytes, count, head)?, + }; let mut cmap = Vec::with_capacity(28 + mappings.len() * 12); cmap.extend_from_slice(&[0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]); cmap.extend_from_slice(&(16_u32 + u32::try_from(mappings.len()).ok()? * 12).to_be_bytes()); @@ -264,6 +268,17 @@ fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap bytes.get_mut(entry + 8..entry + 12)?.copy_from_slice(&offset.to_be_bytes()); bytes.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(cmap.len()).ok()?.to_be_bytes()); bytes.extend_from_slice(&cmap); + // SFNT directories are sorted by tag. Some readers tolerate the unsorted directory of + // a PDF subset, but shaping engines may binary-search it and otherwise miss the cmap. + // Adding a slot increased the directory count; include the new record in this sort. + let tables = usize::from(u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?)); + let end = 12usize.checked_add(tables.checked_mul(16)?)?; + let mut records: Vec<[u8; 16]> = bytes.get(12..end)?.as_chunks::<16>().0.to_vec(); + records.sort_by_key(|r| [r[0], r[1], r[2], r[3]]); + for (index, record) in records.iter().enumerate() { + let at = 12 + index * 16; + bytes.get_mut(at..at + 16)?.copy_from_slice(record); + } if let Some(head) = head { bytes.get_mut(head.checked_add(8)?..head.checked_add(12)?)?.fill(0); let adjustment = 0xb1b0afba_u32.wrapping_sub(checksum(bytes)); @@ -272,6 +287,42 @@ fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap Some(()) } +/// Reserve a new cmap directory slot, retaining every original table payload. +fn insert_editor_cmap_slot(bytes: &mut Vec, mut count: usize, mut head: Option) -> Option<(usize, Option)> { + if count >= 256 { + return None; + } + let end = 12usize.checked_add(count.checked_mul(16)?)?; + bytes.get(..end)?; + // Keep all original table bytes, shifting them by one directory record. + for i in 0..count { + let at = 12 + i * 16; + let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?); + let len = u32::from_be_bytes(bytes.get(at + 12..at + 16)?.try_into().ok()?); + if (offset as usize) < end { + return None; + } + bytes.get(offset as usize..offset.checked_add(len)? as usize)?; + } + bytes.splice(end..end, [0; 16]); + for i in 0..count { + let at = 12 + i * 16; + let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?).checked_add(16)?; + bytes.get_mut(at + 8..at + 12)?.copy_from_slice(&offset.to_be_bytes()); + } + head = head.and_then(|offset| offset.checked_add(16)); + bytes.get_mut(end..end + 4)?.copy_from_slice(b"cmap"); + count += 1; + let count = u16::try_from(count).ok()?; + let selector = count.ilog2() as u16; + let range = (1u16.checked_shl(u32::from(selector))?).checked_mul(16)?; + bytes.get_mut(4..6)?.copy_from_slice(&count.to_be_bytes()); + bytes.get_mut(6..8)?.copy_from_slice(&range.to_be_bytes()); + bytes.get_mut(8..10)?.copy_from_slice(&selector.to_be_bytes()); + bytes.get_mut(10..12)?.copy_from_slice(&count.checked_mul(16)?.checked_sub(range)?.to_be_bytes()); + Some((end, head)) +} + fn descriptor_flags(doc: &Document, descriptor: &Dict) -> u32 { let value = descriptor.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) as i64; u32::try_from(value).unwrap_or(0) @@ -1507,3 +1558,7 @@ mod encoding_tests { assert_eq!(budget.remaining(), 0); } } + +mod thai; +use thai::composite_glyph_maps; +pub use thai::{LegacyThaiFont, recovered_thai_map}; diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index c2acef191c..f6051feedd 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -795,3 +795,44 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!(covered, [5, 6, 7, 100, 101]); assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } + +#[test] +fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let records: Vec<_> = subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|r| &r[..4] != b"cmap").map(|r| r.to_vec()).collect(); + subset[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (index, record) in records.iter().enumerate() { + subset[12 + index * 16..28 + index * 16].copy_from_slice(record); + } + assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err()); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut gids = vec![0; 36]; + gids[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes()); + let gids = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &gids))); + let mut descendant = Dict::new(); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids)); + let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar"))); + let source = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])), + ("ToUnicode", Object::Ref(unicode)), + ], + ); + let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too"); + assert_eq!(shaped.glyphs[0].id, expected); + let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") }; + assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact"); +} diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs new file mode 100644 index 0000000000..54784c291c --- /dev/null +++ b/crates/fonts/src/pdf/thai.rs @@ -0,0 +1,372 @@ +//! Recover a narrowly identified legacy Thai exporter map, not arbitrary missing Unicode. +//! +//! 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. + +use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; +use pdfcraft_cos::{DecodeBudget, Object}; +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> { + 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") + || font.name(b"Subtype") != Some(b"Type0") + || font.name(b"Encoding") != Some(b"Identity-H") + { + return None; + } + let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; + if desc.name(b"CIDToGIDMap") != Some(b"Identity") { + return None; + } + let map = unicode_map(doc, font, true); + // Five separated consonants and three contextual marks must agree, together with the + // corrupt destinations and corrupt range. This is deliberately stricter than font-name + // detection: a renamed/subsetted font with different glyph order must not be guessed. + let fingerprint = [ + (217, '\u{0e04}'), + (226, '\u{0e0d}'), + (250, '\u{0e25}'), + (256, '\u{0e2b}'), + (258, '\u{0e2d}'), + (356, '\u{0e4c}'), + (358, '\u{0e31}'), + (360, '\u{0e47}'), + (214, '\u{000f}'), + (240, ')'), + (248, '#'), + (261, '>'), + (263, '@'), + (265, '>'), + (273, 'N'), + (352, 'N'), + (353, 'O'), + ]; + let matches = |pairs: &[(u32, char)], last: u16| { + pairs.iter().all(|&(code, ch)| map.text(code).is_some_and(|t| t.chars().eq([ch]))) + && map.ranges.iter().any(|r| r.lo == 255 && r.hi == 511 && r.units.as_ref() == [last]) + }; + // The same exporter emitted three distinct subset maps. Each must match its entire + // intact-anchor and corrupt-destination fingerprint, including the oversized range. + let body = matches(&fingerprint, 0x32); + let attachment = matches( + &[ + (217, '\u{0e04}'), + (256, '\u{0e2b}'), + (258, '\u{0e2d}'), + (297, '\u{0e58}'), + (347, '\u{0e48}'), + (356, '\u{0e4c}'), + (358, '\u{0e31}'), + (360, '\u{0e47}'), + (214, '\u{000f}'), + (226, '\u{000f}'), + (240, '\u{0027}'), + (248, '/'), + (250, '1'), + (265, 'B'), + (273, 'N'), + (352, 'N'), + (353, 'O'), + ], + 0x32, + ); + let bold = matches( + &[ + (217, '\u{0e04}'), + (226, '\u{0e0d}'), + (232, '\u{0e13}'), + (234, '\u{0e15}'), + (236, '\u{0e17}'), + (243, '\u{0e1e}'), + (250, '\u{0e25}'), + (256, '\u{0e2b}'), + (258, '\u{0e2d}'), + (356, '\u{0e4c}'), + (214, '\u{000f}'), + (240, '!'), + (248, '1'), + (263, '@'), + (273, 'N'), + (352, 'N'), + (353, 'O'), + ], + 0x30, + ); + if !(body || attachment || bold) { + return None; + } + let mut out: Vec<_> = (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(); + // Contextual marks have the same character as the nominal mark, with a moved outline. + out.extend([ + (343, '\u{0e34}'), + (344, '\u{0e35}'), + (347, '\u{0e48}'), + (351, '\u{0e4c}'), + (352, '\u{0e48}'), + (353, '\u{0e49}'), + (356, '\u{0e4c}'), + (358, '\u{0e31}'), + (360, '\u{0e47}'), + ]); + Some(out) +} + +/// 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 { + pub shaped: crate::source_shaping::ShapedRun, + pub codes: Vec>, +} + +/// Unicode-to-glyph and glyph-to-PDF-code maps for a two-byte Identity-H TrueType font. +/// Used by the editor-only cmap adapter and by direct source-font shaping. +pub(super) fn composite_glyph_maps( + doc: &Document, + font: &Dict, + bytes: &[u8], + metrics: &Metrics, + budget: &mut DecodeBudget, +) -> Option<(BTreeMap, BTreeMap)> { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + if font.name(b"Encoding") != Some(b"Identity-H") { + return None; + } + let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; + let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { + None => None, + Some(Object::Name(n)) if n == b"Identity" => None, + Some(Object::Stream(s)) => Some(budget.decode_within(s, 1 << 17).ok()?), + _ => return None, + }; + let face = skrifa::FontRef::new(bytes).ok()?; + let loca = face.loca(None).ok()?; + let advances = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(a, b)| b > a); + let glyph_of = |cid: u16| -> Option { + match &cid_map { + None => Some(u32::from(cid)), + Some(map) => { + let at = usize::from(cid).checked_mul(2)?; + Some(u32::from(u16::from_be_bytes(map.get(at..at + 2)?.try_into().ok()?))) + } + } + }; + let mut unicode = BTreeMap::new(); + let mut codes = BTreeMap::new(); + for code in 0..=u16::MAX { + let Some(gid) = glyph_of(code) else { continue }; + if gid == 0 || gid as usize >= loca.len().saturating_sub(1) { + continue; + } + let text = metrics.text_of(u32::from(code)); + let ch = text.as_deref().and_then(only_char); + let space = ch == Some(' ') && advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| w > 0.0); + if !outlined(gid) && !space { + continue; + } + codes.entry(gid).or_insert(code); + if let Some(ch) = ch.filter(|c| !c.is_control()) { + unicode.entry(u32::from(ch)).and_modify(|old: &mut u32| *old = (*old).min(gid)).or_insert(gid); + } + } + // The verified THSarabunPSK glyph order is retained by these PDF subsets. Restore the + // Windows Thai PUA alternates for HarfRust's legacy shaper, only where outlines survive. + // 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 == "้"); + if family.starts_with("THSarabunPSK") + && unicode.get(&0x0e01) == Some(&214) + && unicode.get(&0x0e2d) == Some(&258) + && (alternate_meanings || unicode.contains_key(&0xf70a)) + { + 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(), + } + { + unicode.insert(0xf700 + index as u32, gid); + codes.entry(gid).or_insert(code); + } + } + } + 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. +pub struct LegacyThaiFont { + bytes: Vec, + codes: BTreeMap, + space: f64, +} + +impl LegacyThaiFont { + /// Read and verify the source font once for all wrapping/preview measurements. + 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); + } + if !space.is_finite() || space < 0.0 { + 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() { + 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 })) + } + + /// Shape into glyphs that are already in this exact source face. + pub fn shape(&self, text: &str) -> Result { + use skrifa::MetadataProvider as _; + 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)?; + 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 + // positioning gap, rather than drawing .notdef or changing to another font. + 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)?; + for mut glyph in run.glyphs { + let code = self + .codes + .get(&glyph.id.to_u32()) + .ok_or("the Thai subset lacks a required contextual glyph; load the complete original face")?; + glyph.position[0] += shaped.advance; + codes.push(code.to_be_bytes().to_vec()); + shaped.glyphs.push(glyph); + } + shaped.advance += run.advance; + } + if piece.ends_with(' ') { + shaped.glyphs.push(crate::source_shaping::ShapedGlyph { + id: skrifa::GlyphId::NOTDEF, + position: [shaped.advance, 0.0], + text: " ".into(), + }); + codes.push(Vec::new()); + shaped.advance += self.space; + } + if shaped.glyphs.len() > 65_534 || !shaped.advance.is_finite() { + return Err("Thai source row exceeds the shaping budget".into()); + } + } + Ok(ExistingThaiRun { shaped, codes }) + } +} + +/// Verify every context-dependent PUA alternate before asking the legacy shaper to use it. +/// Otherwise a subset missing an alternate would silently draw the wrong high/left mark. +fn require_pua(cmap: &skrifa::charmap::Charmap<'_>, text: &str) -> Result<(), String> { + let mut base = 0u32; + let mut above = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + let c = u32::from(ch); + let ascender = matches!(base, 0xe1b | 0xe1d | 0xe1f); + let pua = match c { + 0xe31 if ascender => Some(0xf710), + 0xe34..=0xe37 if ascender => Some(0xf701 + c - 0xe34), + 0xe47 if ascender => Some(0xf712), + 0xe4d if ascender => Some(0xf711), + 0xe48..=0xe4c => { + let high = above || chars.peek().is_some_and(|c| *c == 'ำ'); + if ascender { + Some((if high { 0xf713 } else { 0xf705 }) + c - 0xe48) + } else if !high { + Some(0xf70a + c - 0xe48) + } else { + None + } + } + 0xe38..=0xe3a if matches!(base, 0xe0e | 0xe0f) => Some(0xf718 + c - 0xe38), + 0xe38..=0xe3a if base == 0xe0d => Some(0xf70f), + 0xe38..=0xe3a if base == 0xe10 => Some(0xf700), + 0xe33 if ascender => Some(0xf711), + _ => None, + }; + if pua.is_some_and(|p| char::from_u32(p).is_none_or(|c| cmap.map(c).is_none())) { + return Err(format!( + "the Thai subset lacks a required contextual mark for {ch} (U+{c:04X}); load the complete original face or explicitly select a replacement font" + )); + } + if matches!(c, 0xe01..=0xe2e) { + base = c; + above = false; + } else if matches!(c, 0xe31 | 0xe34..=0xe37 | 0xe47 | 0xe4d..=0xe4e) { + above = true; + } else if !matches!(c, 0xe38..=0xe3a | 0xe48..=0xe4c) { + base = 0; + above = false; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::recovered_thai_map; + use pdfcraft_cos::{Dict, Document, Object}; + use std::collections::HashMap; + + #[test] + fn damaged_legacy_thai_map_recovers_consonants_vowels_and_contextual_tones_only_for_its_fingerprint() { + let mut doc = Document::new_empty(); + let data = b"begincmap 1 begincodespacerange <0000> endcodespacerange 18 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> <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)); + // 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") + .replace(" <00ff><01ff><0032> endbfrange endcmap", " endbfrange endcmap"); + doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), data.as_bytes()))); + let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); + let title: String = [240, 248, 261, 214, 263, 253].into_iter().map(|code| recovered[&code]).collect(); + assert_eq!(title, "ประกาศ"); + assert_eq!(recovered[&265], 'ิ'); + assert_eq!(recovered[&273], 'เ'); + assert_eq!(recovered[&352], '่'); + assert_eq!(recovered[&353], '้'); + font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+Unrelated")); + assert!(recovered_thai_map(&doc, &font).is_none()); + font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK")); + doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <00d9><0e04> endbfchar"))); + assert!(recovered_thai_map(&doc, &font).is_none(), "one plausible anchor cannot authorize guessing a map"); + } +} diff --git a/crates/fonts/src/source_shaping.rs b/crates/fonts/src/source_shaping.rs index b6b28ca692..6976bd8a0e 100644 --- a/crates/fonts/src/source_shaping.rs +++ b/crates/fonts/src/source_shaping.rs @@ -22,7 +22,23 @@ 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 { + shape_inner(bytes, text, false) +} + +/// Only used after the PDF adapter has verified legacy Thai PUA glyph mappings. +pub(crate) fn shape_legacy_thai(bytes: &[u8], text: &str) -> Result { + shape_inner(bytes, text, true) +} + +fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result { if text.len() > 262_144 || text.contains(['\n', '\r']) { return Err("shape one bounded text row at a time".into()); } @@ -30,7 +46,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 !legacy_thai && 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); @@ -56,7 +77,11 @@ pub fn shape(bytes: &[u8], text: &str) -> Result { let mut seen = std::collections::HashSet::new(); for (g, p) in output.glyph_infos().iter().zip(output.glyph_positions()) { if g.glyph_id == 0 { - return Err("the full font lacks a required contextual glyph; choose the complete source face".into()); + let missing = input.get(g.cluster as usize..).and_then(|s| s.chars().next()).unwrap_or('\u{fffd}'); + return Err(format!( + "the full font lacks a required contextual glyph for {missing} (U+{:04X}); choose the complete source face", + u32::from(missing) + )); } let start = g.cluster as usize; let end = starts.get(starts.partition_point(|i| *i <= start)).copied().unwrap_or(input.len()); @@ -74,3 +99,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()))); + } +} From c7e8d127afb9ac22e6248eeebbc838bf6b2aa2d7 Mon Sep 17 00:00:00 2001 From: xullul <32987931+xullul@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:29:47 +0700 Subject: [PATCH 02/21] M7: edit grouped Thai text while preserving source formatting Add: - Prepare supported Form placements as original page operators through the existing-text UI and text_prepare, with shared-form isolation and undo/redo. - Repair narrowly identified Thai exporter maps on copied page fonts, recording repairs without changing the embedded source glyph programs. Change: - Reuse legacy Thai Regular/Bold glyph codes and same-face punctuation subsets; retain point size, spacing, horizontal scale, rise, colour and paint settings. - Keep cross-family Thai replacement explicit and write matching bundled Thai subsets through the existing bounded outline path rather than static embedding. - Keep replacement-only menu choices out of the source-font preview atlas so opening an ordinary paragraph does not wait an extra frame for unused fonts. - Refuse unsafe compositing, tagged or malformed groups, and missing contextual glyphs without losing the user's document or changing other placements. Chores: - Document the editing limits and headless workflow; keep text-edit parity partial. Builds on dekkeng's Thai fallback work in #2811 (fba0a29a) and mtlcl410's Form Ungroup implementation in #3067 (6eafca9c), adapted to the current module layout. The Ungroup menu and older UI font-priority changes are not included. Refs #3185, #2964 and #2142. Synthetic regressions cover source-font preservation, shared forms, unsafe refusals, layout, undo/redo, rendering and save/reopen. Validation: library suites 1,675 passed; automation integration 331 passed; document-font integration 2 passed; editor flows 248 passed. Existing ignored tests remain. Formatting, scoped strict Clippy, layers, assets, parity and wasm checks passed. The unchanged opacity regression failed before the choice-only atlas correction and passed afterwards. --- ROADMAP.md | 3 + crates/automation/README.md | 5 + crates/automation/src/dispatch.rs | 9 + crates/automation/src/tools.rs | 7 +- crates/automation/tests/it/automation.rs | 115 ++++ crates/edit/src/added.rs | 1 + crates/edit/src/added/draw.rs | 52 ++ crates/edit/src/images.rs | 2 +- crates/edit/src/tests.rs | 228 +++++++ crates/edit/src/text.rs | 3 +- crates/edit/src/text/blocks.rs | 2 +- crates/edit/src/text/editor.rs | 41 +- crates/edit/src/text/fallback.rs | 8 +- crates/edit/src/text/forms.rs | 192 ++++++ crates/edit/src/text/forms/expand.rs | 576 ++++++++++++++++++ crates/edit/src/text_regions.rs | 7 +- crates/edit/src/text_regions/styled.rs | 528 +++++++++++++--- crates/engine/src/document.rs | 14 + crates/engine/src/edit.rs | 4 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 9 + crates/engine/src/history.rs | 1 + crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/page_input.rs | 7 + .../ui-egui/src/tools/edit_text_ui/layout.rs | 4 +- docs/editing-existing-text.md | 35 ++ docs/roadmap.md | 5 +- parity/acrobat-features.toml | 6 +- 28 files changed, 1768 insertions(+), 101 deletions(-) create mode 100644 crates/edit/src/text/forms.rs create mode 100644 crates/edit/src/text/forms/expand.rs diff --git a/ROADMAP.md b/ROADMAP.md index 820ceb0179..988be90914 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged) + > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) @@ -230,6 +232,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-05 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing. Legacy Thai keeps the original font programs, Regular/Bold and text settings; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. diff --git a/crates/automation/README.md b/crates/automation/README.md index db5b988394..9c31754e0d 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -204,6 +204,11 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md) and [image editing](../../docs/editing-images.md). +`text_prepare` makes supported grouped Form text editable on one page, as entering Edit text +in the GUI does. It keeps the group's original glyph operators and shared source objects, +records preparation/map repairs and is undoable. List `text_paragraphs` afterwards; its indices +belong to the prepared page. Unsafe compositing and malformed/tagged groups stay intact. + ### Superscript and subscript normalization `text_script` changes characters to their Unicode superscript or subscript forms; it does not diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index ebe2a93526..0d8c6e2333 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -480,6 +480,15 @@ impl Automation { self.apply(&a, Edit::EditPageImage { page, index, change })? } } + "text_prepare" => { + let page = self.page(&a)?; + let preparation = self.doc(&a)?.text_preparation(page).map_err(ToolError::Failed)?; + let mut result = self.apply(&a, Edit::PrepareText { page })?; + result["prepared_groups"] = json!(preparation.expanded); + result["repaired_fonts"] = json!(preparation.repaired_fonts); + result["refused_groups"] = json!(preparation.refused); + result + } "text_paragraphs" => { let page = self.page(&a)?; let doc = self.doc_to_copy(&a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index af5146377a..c58d803284 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1581,9 +1581,12 @@ pub fn tools() -> Vec { t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.") .cmd("edit.edit_text") .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])), - t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.") + t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. When available, builtin:thai explicitly selects bundled Noto Sans Thai for a paragraph or range; preview_only faces cannot be selected.") .ro().cmd("edit.edit_text") .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), + t("text_prepare", "Prepare grouped text", "Make existing text inside supported Form XObjects directly editable on one page. Replaces placements with their original operators and resources, preserving position, clipping and other pages. Refuses unsafe transparency, tags or malformed content. Adds no text or cover boxes. Returns prepared_groups, repaired_fonts and refused_groups. Undoable; list text_paragraphs again afterwards.") + .cmd("edit.edit_text") + .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.") .ro() .cmd("edit.edit_text") @@ -1597,7 +1600,7 @@ pub fn tools() -> Vec { .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, - "font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." }, + "font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or bundled document-font choice from text_fonts (builtin:thai selects the bundled Thai face); also accepts text_font_import resources." }, "ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." }, "text": { "type": "string" }, "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 96649c8e80..21c958b52e 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -11428,3 +11428,118 @@ fn comments_copy_and_paste_between_documents_through_tools() { assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_)))); assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); } + +#[test] +fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap(); + let mut a = auto(dir); + let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + let before = page_text(&mut a, doc); + let fonts = ok(&mut a, "text_fonts", json!({"doc":doc,"page":1})); + let choice = fonts["fonts"].as_array().unwrap().iter().find(|f| f["resource"] == "builtin:thai"); + let args = json!({"doc":doc,"page":1,"paragraph":1,"text":"น้ำ กิ่ กี้ ผู้ใช้","font_resource":"builtin:thai"}); + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(choice.is_none()); + assert!(a.call("text_edit", &args).is_err()); + assert_eq!(page_text(&mut a, doc), before); + return; + } + assert_eq!(choice.unwrap()["preview_only"], false); + ok(&mut a, "text_edit_layout", args.clone()); + assert_eq!(page_text(&mut a, doc), before); + ok(&mut a, "text_edit", args); + let edited = page_text(&mut a, doc); + assert!(edited[0].contains("น้ำ กิ่ กี้ ผู้ใช้")); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(page_text(&mut a, doc), before); + ok(&mut a, "edit_redo", json!({"doc":doc})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened), edited); + assert!(a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"})).is_err()); + assert_eq!(page_text(&mut a, reopened), edited); + let image = a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":144})).unwrap(); + assert!(image.iter().any(|c| matches!(c, Content::Png { .. }))); +} + +#[test] +fn grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path(); + let mut source = Document::open(Arc::new(fixture(2))).unwrap(); + let old = "ข้อความเดิม น้ำ ผู้ใช้"; + let new = "ข้อความแก้ไข น้ำ ผู้ใช้"; + let mut cmap = String::from("begincmap 1 begincodespacerange <00> endcodespacerange\n"); + cmap.push_str(&format!("{} beginbfchar\n", old.chars().count())); + for (i, ch) in old.chars().enumerate() { + cmap.push_str(&format!("<{:02x}> <{:04x}>\n", 65 + i, u32::from(ch))); + } + cmap.push_str("endbfchar endcmap"); + let unicode = source.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+LegacyThai")); + font.set(b"FirstChar".to_vec(), Object::Int(65)); + font.set(b"LastChar".to_vec(), Object::Int(65 + old.chars().count() as i64 - 1)); + font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(300); old.chars().count()])); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + let font = source.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(font)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(300)])); + form.set(b"Resources".to_vec(), Object::Dict(resources)); + let codes: String = (0..old.chars().count()).map(|i| format!("{:02x}", 65 + i)).collect(); + let form = source.add(Object::Stream(Stream::flate(form, format!("BT /F1 14 Tf 20 220 Td <{codes}> Tj ET").as_bytes()))); + for page in [ObjRef::new(4, 0), ObjRef::new(6, 0)] { + let mut xobjects = Dict::new(); + xobjects.set(b"Body".to_vec(), Object::Ref(form)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = source.add(Object::Stream(Stream::flate(Dict::new(), b"q /Body Do Q"))); + source + .update_dict(page, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(content)); + }) + .unwrap(); + } + std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(dir); + let id = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + let before = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0); + ok(&mut a, "text_prepare", json!({"doc":id,"page":1})); + assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before, "preparing must not change pixels"); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["paragraphs"][0]["text"], old); + let args = json!({"doc":id,"page":1,"paragraph":1,"text":new,"font_resource":"builtin:thai"}); + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(a.call("text_edit", &args).is_err()); + return; + } + ok(&mut a, "text_edit", args); + assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"].as_str().unwrap().trim(), new); + assert_eq!(page_text(&mut a, id)[1], old, "a shared form's other page must not change"); + let after = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + assert_ne!(after, before); + ok(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before); + ok(&mut a, "edit_undo", json!({"doc":id})); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0); + ok(&mut a, "edit_redo", json!({"doc":id})); + ok(&mut a, "edit_redo", json!({"doc":id})); + ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), after); + assert_eq!(page_text(&mut a, reopened), [new, old]); + assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 0, "editing existing text must not create Add Text objects"); +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index a603a37060..4abe4a02eb 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -23,6 +23,7 @@ mod layout; mod store; mod types; +pub(crate) use draw::thai_glyph_resources; #[cfg(test)] use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; #[cfg(test)] diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 9404f939b1..03b00fe392 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -386,3 +386,55 @@ pub(super) fn params(c: &Content) -> Dict { } d } + +/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline +/// writer instead of trying to embed the matching craft-fonts variable program as a static face. +pub(crate) fn thai_glyph_resources( + doc: &mut Document, + fonts: &mut Dict, + shaped: &pdfcraft_fonts::source_shaping::ShapedRun, +) -> Result)>, EditError> { + if shaped.glyphs.len() > 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + let mut outlines = HashMap::new(); + let mut clusters = Vec::new(); + let mut index = HashMap::new(); + let mut indices = Vec::new(); + for glyph in &shaped.glyphs { + let id = glyph.id.to_u32(); + let key = (ShapedScript::Thai, id); + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) { + entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?); + } + let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| { + let at = clusters.len(); + let width = outlines.get(&key).map_or(0.0, |g| g.width); + clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() }); + at + }); + indices.push(at); + } + // Validate all outlines before adding resources. Type 3 character codes are single bytes; + // shard long rows and return the appropriate resource/code for every positioned glyph. + let mut names = Vec::new(); + for chunk in clusters.chunks(GLYPHS_PER_FONT) { + let refs: Vec<_> = chunk.iter().collect(); + let font = type3_shaped(doc, &refs, &outlines); + doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?; + let mut name = format!("PCThai{}", font.num); + while fonts.contains(name.as_bytes()) { + name.push('_'); + } + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + names.push(name); + } + indices + .into_iter() + .map(|at| { + let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; + let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; + Ok((name.clone(), vec![code])) + }) + .collect() +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 72656fa1bb..b4ecd1f74f 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -49,7 +49,7 @@ pub struct PageImage { /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the /// operators of the page's streams joined ([`Pieces`]). pub stream: usize, - op: usize, + pub(crate) op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], clips: Vec, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 22fbd6e8ae..d8f0ca16ee 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10696,3 +10696,231 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { } } } + +#[test] +fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+UnavailableThai")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + f.set(b"FirstChar".to_vec(), Object::Int(65)); + f.set(b"LastChar".to_vec(), Object::Int(65)); + f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(600)])); + }) + .unwrap(); + let before = text::text_lines(&doc, 0).unwrap(); + let choice = text::text_editor_fonts(&doc, 0).unwrap().into_iter().find(|f| f.resource == "builtin:thai"); + let style = text::BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() }; + if pdfcraft_fonts::document_thai_font().is_none() { + assert!(choice.is_none()); + assert!(text::rewrite_block(&mut doc, 0, 0, Some("น้ำ"), &style).is_err()); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, before[0].text); + return; + } + assert!(choice.is_some(), "the document-font menu must offer a bundled Thai replacement"); + let error = text::rewrite_block(&mut doc, 0, 0, Some("นน"), &text::BlockStyle::default()).unwrap_err().to_string(); + assert!(error.contains("UnavailableThai") && error.contains("Noto Sans Thai"), "{error}"); + let draft = "น้ำ กิ่ กี้ ผู้ใช้"; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, before[1].text); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), before[1].baseline()); + text::rewrite_block(&mut doc, 0, 0, Some("น้ำ ปู่"), &text::BlockStyle::default()).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "น้ำ ปู่"); + // A selected range uses the same explicit choice; neighbouring source runs keep their face. + let mut selected = text_page("BT /F1 16 Tf 50 700 Td (Ab) Tj ET"); + let style = text::BlockStyle { + ranges: vec![text::RangeStyle { + range: 0..1, + resource: Some("builtin:thai".into()), + family: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }], + ..Default::default() + }; + text::rewrite_block(&mut selected, 0, 0, Some("Ab"), &style).unwrap(); + let lines = text::text_lines(&reopen(&selected), 0).unwrap(); + assert!(lines.iter().any(|l| l.text == "A" && l.base_font.contains("NotoSansThai"))); + assert!(lines.iter().any(|l| l.text == "b" && l.base_font == "Helvetica")); +} + +#[test] +fn existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font() { + let Some(_) = pdfcraft_fonts::document_thai_font() else { return }; + // Synthetic source subset: only one Thai letter is encoded. No binary fixture or font + // is committed; the complete, openly licensed matching face is the optional build input. + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); + let cmap = + doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar"))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + let neighbour = text::text_lines(&doc, 0).unwrap()[1].clone(); + // More than 240 distinct logical clusters requires several single-byte Type 3 resources. + let long: String = + "กขคงจฉชซญดตถทนบปผฝพฟมยรลวศษสหฬอฮ".chars().flat_map(|c| ["ิ่", "ิ้", "ี่", "ี้", "ึ่", "ึ้", "ื่", "ื้"].map(move |marks| format!("{c}{marks}"))).collect(); + let draft = format!("{long} ผู้ใช้ ปู่ ๑๒๓"); + let style = text::BlockStyle { width: Some(14_400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].baseline(), [50.0, 700.0]); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().text, neighbour.text); + assert_eq!(text::text_lines(&doc, 0).unwrap().last().unwrap().baseline(), neighbour.baseline()); + let next = "Hello ผู้ใช้ น้ำ ๔๕๖"; + text::rewrite_block(&mut doc, 0, 0, Some(next), &text::BlockStyle::default()).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, next); +} + +#[test] +fn grouped_source_text_preparation_edits_original_operators_without_changing_shared_forms() { + let mut doc = page_with_forms("q 1 0 0 1 12 20 cm /Body Do Q", &[("Body".into(), "BT /F1 14 Tf 40 500 Td (Original body) Tj ET".into())]); + let original_form = doc.get(pdfcraft_cos::ObjRef::new(7, 0)).clone(); + let first = pdfcraft_model::pages(&doc)[0].clone(); + let second = doc.add(Object::Dict(first.dict.clone())); + doc.update_dict(pdfcraft_cos::ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first.obj), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + assert!(!text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text.contains("Original body"))); + let prepared = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(prepared.expanded, 1); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == "Original body").unwrap(); + assert!((block.rect[0] - 52.0).abs() < 0.01, "placement must survive preparation: {block:?}"); + rewrite_block(&mut doc, 0, index, Some("Changed body"), &BlockStyle::default()).unwrap(); + assert!( + !text::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).unwrap().iter().any(|l| l.text.contains("Original body")), + "old glyphs must be removed, not covered" + ); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "Changed body")); + assert!(text::reading_blocks(&doc, 1).unwrap().iter().any(|b| b.text == "Original body")); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(7, 0)), original_form); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 0); +} + +#[test] +fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact() { + for (page_ops, form_ops, extra) in [ + ("/Body Do", "BT /F1 14 Tf (body) Tj ET", "/Group << /S /Transparency /K true >>"), + ("/Body Do", "Q BT /F1 14 Tf (body) Tj ET", ""), + ("/Body Do", "BT /F1 14 Tf (body) Tj", ""), + ("10 10 m 20 20 l /Body Do S", "BT /F1 14 Tf (body) Tj ET", ""), + ("BT /F1 14 Tf /Body Do ET", "BT /F1 14 Tf (body) Tj ET", ""), + ("/Body Do", "/Body Do", "/Group << /S /Transparency >>"), + ] { + let mut doc = page_with_forms(page_ops, &[("Body".into(), form_ops.into())]); + if !extra.is_empty() { + let additions = build(&[&format!("<< {extra} >>")]); + let fields = additions.get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + let form = doc.get(pdfcraft_cos::ObjRef::new(7, 0)); + let Object::Stream(mut s) = form.as_ref().clone() else { panic!("fixture") }; + for (k, v) in fields.iter() { + s.dict.set(k.clone(), v.clone()); + } + doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(s)); + } + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 0, "unsafe input {page_ops}, {form_ops}, {extra}"); + assert!(!result.refused.is_empty()); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); + } +} + +#[test] +fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen() { + // Remap existing OFL Inter outlines to synthetic Thai glyph slots; no proprietary font + // fixture is needed. Removing layout/cmap tables reproduces legacy PDF font subsetting. + let mut program = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + let count = u16::from_be_bytes(program[4..6].try_into().unwrap()) as usize; + let records: Vec<_> = program[12..12 + count * 16] + .as_chunks::<16>() + .0 + .iter() + .filter(|r| !matches!(&r[..4], b"cmap" | b"GSUB" | b"GPOS")) + .map(|r| r.to_vec()) + .collect(); + program[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (index, record) in records.iter().enumerate() { + program[12 + index * 16..28 + index * 16].copy_from_slice(record); + } + for bold in [false, true] { + let mut doc = text_page("BT /F1 17.5 Tf 0.42 Tc 1.1 Tw 86 Tz 1.5 Ts 2 Tr 0.6 w 0.2 0.3 0.4 rg 50 650 Td <00d601090119> Tj ET"); + let raw = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(raw)); + let mut descendant = Dict::new(); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); + descendant.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(65535), Object::Int(500)])); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar", + ))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type0")); + font.set(b"BaseFont".to_vec(), Object::name(if bold { "ABCDEF+THSarabunPSK-Bold" } else { "ABCDEF+THSarabunPSK" })); + font.set(b"Encoding".to_vec(), Object::name("Identity-H")); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(descendant)])); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font.clone())); + let before = text::text_lines(&doc, 0).unwrap()[0].clone(); + let fonts = page_font_programs(&doc); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + let edited = text::rewrite_block(&mut doc, 0, 0, Some("กิ่ กี้ น้ำ"), &style).expect("existing source glyphs must remain editable"); + assert_eq!(edited.substituted, None, "keeping the source must never silently select Noto or another face"); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines[0].text, "กิ่ กี้ น้ำ"); + for after in &lines { + assert_eq!(after.font, before.font); + assert_eq!(after.base_font, before.base_font); + assert_eq!(after.bold, bold); + assert_eq!(after.size, before.size); + assert_eq!(after.color, before.color); + assert_eq!(after.baseline(), before.baseline()); + } + let mut settings = std::collections::BTreeMap::new(); + for op in pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops { + if matches!(op.op.as_slice(), b"Tc" | b"Tw" | b"Tz" | b"Ts" | b"Tr" | b"w") { + settings.insert(op.op.clone(), op.num(0).unwrap()); + } + let shows = if op.is("Tj") { + op.operands.first().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()) + } else if op.is("TJ") { + op.operands + .first() + .and_then(Object::as_array) + .is_some_and(|a| a.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty()))) + } else { + false + }; + if shows { + break; + } + } + for (op, expected) in [("Tc", 0.42), ("Tw", 1.1), ("Tz", 86.0), ("Ts", 1.5), ("Tr", 2.0), ("w", 0.6)] { + assert_eq!(settings.get(op.as_bytes()), Some(&expected), "{op} must survive in the saved PDF"); + } + let mut explicit = doc.clone(); + let chosen = BlockStyle { resource: Some("F1".into()), width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut explicit, 0, 0, Some("กี้ น้ำ"), &chosen).expect("selecting the existing source font from the menu must work too"); + assert_eq!(text::text_lines(&reopen(&explicit), 0).unwrap()[0].base_font, before.base_font); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict(), Some(&font)); + let Object::Stream(saved) = doc.get(raw).as_ref().clone() else { panic!("fixture font") }; + assert_eq!(saved.decoded().unwrap(), program); + assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added"); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3d1626a576..fd6f90e79f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -31,6 +31,7 @@ pub use session::TextSession; mod blocks; mod editor; mod fallback; +pub mod forms; mod interp; mod lines; mod rewrite; @@ -42,8 +43,8 @@ pub use blocks::{ BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; -use editor::layout_draft; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; +use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 48fe956f4f..a459073345 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -400,7 +400,7 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) pub struct BlockStyle { /// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style. pub ranges: Vec, - /// Explicit page font resource (from register_text_font or the page font inventory). + /// Explicit page font resource or bundled document-font choice from the page font inventory. pub resource: Option, /// A standard font family, bold, italic. pub family: Option<(crate::added::Family, bool, bool)>, diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index f7aa1f47bd..4f4abf3a43 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,15 +2,30 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; +use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession}; -/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and -/// are never installed, copied into assets, or used as a font for another document. +/// An embedded source font or an approved bundled document-font choice. +/// Source bytes stay in memory and are never installed or copied into another document. #[derive(Clone, Debug)] pub struct TextEditorFont { pub resource: String, pub face: String, pub bytes: Vec, + /// A selectable replacement, rather than a source/matching preview face. Merely listing + /// this choice must not reload an open editor's font atlas. + pub choice_only: bool, +} + +/// Reserved document-font choice for explicitly replacing text with the bundled Thai face. +/// This is a selector, never a font resource written into a PDF. +const BUILTIN_THAI_FONT: &str = "builtin:thai"; + +pub(super) fn builtin_thai_choice(fonts: &Dict) -> String { + let mut choice = BUILTIN_THAI_FONT.to_owned(); + while fonts.contains(choice.as_bytes()) { + choice.push('_'); + } + choice } pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, EditError> { @@ -32,6 +47,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result Result bool { font.name(b"Subtype") == Some(b"Type3") - && font - .name(b"Name") - .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) + && font.name(b"Name").is_some_and(|n| { + n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes() || n == b"PCThai" + }) } /// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs new file mode 100644 index 0000000000..71339af64b --- /dev/null +++ b/crates/edit/src/text/forms.rs @@ -0,0 +1,192 @@ +//! Make text in ordinary Form XObjects available to the existing paragraph editor. +//! +//! This replaces each supported placement's `Do`, never the shared form object. The same +//! source glyph operators become page content; no cover rectangle or replacement text box is +//! added. Unsupported compositing and malformed streams stay intact. + +mod expand; + +use pdfcraft_content::{Matrix, Pieces}; +use pdfcraft_cos::{Dict, Document, Object}; + +use super::{page_dict, rewritten_contents, splice}; +use crate::EditError; + +/// Result of preparing one page. Refused groups remain in the page, with an actionable reason. +#[derive(Clone, Debug, Default)] +pub struct Preparation { + pub expanded: usize, + pub refused: Vec, + pub repaired_fonts: usize, +} + +/// Prepare grouped source text atomically, within eight levels and 64 placements per page. +/// Shared forms and other pages are untouched. Preparation is a distinct undoable engine edit. +pub fn prepare(doc: &mut Document, page: usize) -> Result { + let mut next = doc.clone(); + let mut result = Preparation::default(); + let mut bytes_left = 16usize << 20; + let mut visits = 0usize; + let mut decode = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + for _ in 0..8 { + let p = page_dict(&next, page)?; + let streams = strict_streams(&next, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, b)| b.as_slice()).collect::>()); + let parsed = pieces.parse(); + if parsed.skipped > 0 { + return Err(EditError::Invalid("the page has unreadable content; grouped text was left unchanged".into())); + } + let ops = parsed.ops; + let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let space = expand::page_blend_space(&next, &p.dict); + let images: std::collections::BTreeMap<_, _> = + crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect(); + let mut replacements = std::collections::HashMap::new(); + let mut merged = resources; + let mut pending_path = false; + let mut in_text = false; + for (at, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => pending_path = true, + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => pending_path = false, + b"BT" => in_text = true, + b"ET" => in_text = false, + _ => {} + } + let Some(image) = images.get(&at) else { continue }; + visits = visits.saturating_add(1); + if visits > 64 { + return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + } + if pending_path || in_text { + result.refused.push(format!("{}: the group is drawn inside an unfinished path or text object", image.name)); + continue; + } + let translucent = expand::translucent_at(&next, &merged, &ops, at); + match expand::ungroup_ops(&next, &merged, image.name.as_bytes(), translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + Ok((inline, mut res, patterns)) => { + // Pure vector artwork needs no preparation. Nested text groups are visited + // on the next pass, with their inherited placement and clip still in force. + if !inline.iter().any(|o| matches!(o.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do")) { + continue; + } + let data_len = pdfcraft_content::serialize_ops(&inline).len(); + bytes_left = bytes_left.checked_sub(data_len).ok_or_else(|| { + EditError::Invalid("grouped page content exceeds the 16 MiB editing budget; the page was left unchanged".into()) + })?; + if !patterns.is_empty() { + let mut table = res.get(b"Pattern").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + for (name, value) in patterns { + table.set(name, Object::Ref(next.add(value))); + } + res.set(b"Pattern".to_vec(), Object::Dict(table)); + } + merged = res; + replacements.insert(at, inline); + result.expanded += 1; + } + Err(error) => result.refused.push(format!("{}: {error}", image.name)), + } + } + if replacements.is_empty() { + break; + } + let changed = splice(&pieces, &ops, |at| match replacements.remove(&at) { + Some(ops) => (ops, false), + None => (Vec::new(), true), + }); + let contents = rewritten_contents(&mut next, &streams, changed); + next.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(merged)); + })?; + } + result.refused.sort(); + result.refused.dedup(); + if result.expanded > 0 { + for reason in &result.refused { + next.note_repair(format!("Grouped text left intact: {reason}")); + } + result.repaired_fonts = repair_thai_fonts(&mut next, page)?; + next.note_repair(format!( + "Prepared {} grouped text placement(s) on page {} for direct source-text editing", + result.expanded, + page.saturating_add(1) + )); + *doc = next; + } + Ok(result) +} + +/// A font is copied into this page: repairing the map must never alter a shared form/font. +fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result { + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut count = 0; + let mut candidates = 0usize; + for (name, value) in fonts.clone().iter() { + let Some(mut font) = doc.resolve(value).as_dict().cloned() else { continue }; + if font.name(b"BaseFont").is_some_and(|n| String::from_utf8_lossy(n).contains("THSarabunPSK")) { + candidates += 1; + if candidates > 64 { + return Err(EditError::Invalid("too many legacy Thai font maps to repair safely; the page was left unchanged".into())); + } + } else { + continue; + } + let Some(map) = pdfcraft_fonts::pdf::recovered_thai_map(doc, &font) else { continue }; + // Append corrective bfchar entries after the original entries so valid Latin mappings + // remain. The original map's accidental oversized range is still overridden for Thai. + let Some(Object::Stream(tu)) = font.get(b"ToUnicode").map(|r| doc.resolve(r)).as_deref().cloned() else { continue }; + let old = pdfcraft_cos::DecodeBudget::new(1 << 20, 0).decode_strict(&tu)?; + let marker = b"endcmap"; + let Some(at) = old.windows(marker.len()).position(|w| w == marker) else { continue }; + let mut added = format!("\n{} beginbfchar\n", map.len()); + for (code, ch) in map { + added.push_str(&format!("<{code:04x}> <{:04x}>\n", u32::from(ch))); + } + added.push_str("endbfchar\n"); + let mut bytes = old.get(..at).unwrap_or_default().to_vec(); + bytes.extend_from_slice(added.as_bytes()); + bytes.extend_from_slice(old.get(at..).unwrap_or_default()); + let tu = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &bytes))); + font.set(b"ToUnicode".to_vec(), Object::Ref(tu)); + let object = doc.add(Object::Dict(font)); + fonts.set(name.clone(), Object::Ref(object)); + count += 1; + } + if count > 0 { + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + doc.note_repair(format!( + "Recovered the damaged legacy THSarabunPSK Thai character map in {count} page font(s); original glyph programs were preserved" + )); + } + Ok(count) +} + +/// Editing cannot infer the state after a damaged page stream. Read all pieces strictly before +/// expanding a placement, rather than treating an undecodable piece as empty. +fn strict_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + let mut budget = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + let list = match page.get(b"Contents") { + None => Vec::new(), + Some(value) => match doc.resolve(value).as_ref() { + Object::Null => Vec::new(), + Object::Array(a) if a.len() <= 4096 => a.clone(), + Object::Array(_) => return Err(EditError::Invalid("too many page content streams to prepare safely".into())), + _ => vec![value.clone()], + }, + }; + list.into_iter() + .map(|value| { + let resolved = doc.resolve(&value); + let Object::Stream(stream) = resolved.as_ref() else { + return Err(EditError::Invalid("the page has a missing or unreadable content stream".into())); + }; + let bytes = budget.decode_strict(stream)?; + Ok((value, bytes)) + }) + .collect() +} diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs new file mode 100644 index 0000000000..27cbf1b48e --- /dev/null +++ b/crates/edit/src/text/forms/expand.rs @@ -0,0 +1,576 @@ +//! Existing-text preparation: draw a Form XObject's content in place of its `Do` (#2964, #3005). +//! Adapted from mtlcl410's Ungroup implementation in PR #3067, revision +//! `6eafca9caa4a4d2174a395b53914bc9e689097cc` (MIT OR Apache-2.0). +//! +//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own +//! content streams. Ungrouping copies the form's content into the page at the same place, +//! wrapped as `q cm re W n … Q`, with the form's resources merged into a copy of +//! the page's under names that don't clash. Its text then edits like any other. The form object +//! itself is left alone, so another page or placement that draws it is unchanged. +//! +//! Drawing a form inline paints the same pixels unless the form is a transparency group whose +//! compositing differs from painting its parts one by one: a knockout group, a group that uses +//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft +//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents +//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would +//! composite in the page's. Those are refused rather than changed, as is tagged form content (its structure link +//! would be lost) and content whose marked content or text objects don't balance (inlined, they +//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`. +//! +//! A pattern's matrix maps pattern space to the default space of the content stream that uses it +//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is +//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose +//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused. + +use std::collections::HashMap; + +use pdfcraft_content::{Matrix, Op, parse}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; + +use crate::EditError; + +/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above +/// any real form, and a bomb must not be copied into the page. +const MAX_FORM_BYTES: usize = 16 << 20; + +/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). +const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; + +/// The most distinct patterns one ungrouped form may select; each becomes a new object. +const MAX_MOVED_PATTERNS: usize = 256; + +/// A name map for one resource category: form name → name in the page's resources. +type Renames = Vec<(Vec, Vec)>; + +/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with +/// the form's merged in, and the pattern copies to add to the document, each under the name in +/// the merged `/Pattern` dictionary that the ops already use. +pub(crate) type Ungrouped = (Vec, Dict, Vec<(Vec, Object)>); + +/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state +/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a +/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). +/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, +/// then the CTM there). +pub(crate) fn ungroup_ops( + doc: &Document, + resources: &Dict, + name: &[u8], + translucent: bool, + page_space: Option<&Object>, + placement: Matrix, + budget: &mut DecodeBudget, +) -> Result { + let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; + let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; + let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; + if !is_name(doc, &s.dict, b"Subtype", b"Form") { + return Err(refuse("it is not a Form XObject")); + } + let numbers = |key: &[u8], n: usize| -> Option> { + let a = s.dict.get(key).map(|o| doc.resolve(o))?; + if a.as_array()?.len() != n { + return None; + } + let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) + }; + let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?; + let matrix = match s.dict.get(b"Matrix") { + None => None, + Some(_) => Some(Matrix( + <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?) + .map_err(|_| refuse("its matrix is unreadable"))?, + )), + }; + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + if s.dict.get(b"Resources").is_some() && own.is_none() { + return Err(refuse("its resources cannot be read")); + } + // A form without its own resources uses the ones it is drawn with (PDF 1.1). + let form_res = own.clone().unwrap_or_else(|| resources.clone()); + + // A tagged form's content is a structure element's: drawn inline it would lose that link. + if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() { + return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags")); + } + let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency")); + if s.dict.get(b"Group").is_some() && group.is_none() { + return Err(refuse("its group dictionary cannot be read")); + } + if let Some(group) = &group { + if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { + return Err(refuse("it is a knockout transparency group")); + } + if translucent { + return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); + } + // Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS); + // inline they would composite in the page's. + if let Some(cs) = group.get(b"CS") + && !same_blend_space(doc, cs, page_space) + { + return Err(refuse("it is a transparency group that blends in a colour space of its own")); + } + } + + // Strictly: damaged data must not be copied in as a shorter form. + let data = budget.decode_strict(s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; + let parsed = parse(&data); + if parsed.skipped > 0 { + return Err(refuse("its content has parts that can't be read")); + } + let mut ops = parsed.ops; + { + // Check every form, including non-group forms, for recursive/unsupported content. + // The form itself is being read: drawing itself again counts as blending. + let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget }; + if blends_inside(doc, &form_res, &ops, 0, &mut walk) { + return Err(refuse("it has blend modes, recursive forms, or nested content that cannot be verified")); + } + } + + // Merge the form's resources into a copy of the page's, renaming what clashes. + let mut merged = resources.clone(); + let mut renames: Vec<(&[u8], Renames)> = Vec::new(); + if own.is_some() { + for cat in CATEGORIES { + let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; + if theirs.len() > 4096 { + return Err(refuse("too many resources")); + } + let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + if mine.len() > 4096 { + return Err(refuse("too many resources")); + } + let mut map = Renames::new(); + let mut n = 0u32; + for (key, value) in theirs.iter() { + if cat == b"ColorSpace" && matches!(key.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") && mine.get(key) != Some(value) { + return Err(refuse("it overrides a default colour space")); + } + if mine.get(key) == Some(value) { + continue; + } + let fresh = loop { + n = n.saturating_add(1); + let candidate = format!("PCUg{n}").into_bytes(); + if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() { + break candidate; + } + if n == u32::MAX { + return Err(refuse("too many resources")); + } + }; + mine.set(fresh.clone(), value.clone()); + map.push((key.to_vec(), fresh)); + } + merged.set(cat.to_vec(), Object::Dict(mine)); + if !map.is_empty() { + renames.push((cat, map)); + } + } + } + let renamed = |cat: &[u8], name: &[u8]| -> Option> { + renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone()) + }; + // A nested form, Type 3 font or tiling pattern without resources of its own looks its names + // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it + // would find the page's, which differ wherever a name was renamed (every form resource that + // isn't already the page's, the nested item's own entry included). + if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { + return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources")); + } + let mut depth = 0usize; + // Marked content and text objects must balance within the form's own stream: an extra EMC + // would close one the page opened, and one left open would take in the page's later content. + let mut marked = 0usize; + let mut in_text = false; + for op in &mut ops { + match op.op.as_slice() { + b"BDC" | b"BMC" => marked = marked.saturating_add(1), + b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?, + b"BT" if in_text => return Err(refuse("its content begins a text object inside another")), + b"BT" => in_text = true, + b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")), + b"ET" => in_text = false, + _ => {} + } + let slot: Option<(&[u8], usize)> = match op.op.as_slice() { + b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => { + let cat: &[u8] = match op.op.as_slice() { + b"Tf" => b"Font", + b"Do" => b"XObject", + b"gs" => b"ExtGState", + b"sh" => b"Shading", + _ => b"ColorSpace", + }; + Some((cat, 0)) + } + b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)), + b"BDC" | b"DP" => Some((&b"Properties"[..], 1)), + b"q" => { + if depth >= 4096 { + return Err(refuse("graphics state is too deeply nested")); + } + depth += 1; + None + } + b"Q" => { + depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?; + None + } + _ => None, + }; + if let Some((cat, i)) = slot + && let Some(operand) = op.operands.get_mut(i) + && let Object::Name(n) = operand + && let Some(to) = renamed(cat, n) + { + *operand = Object::Name(to); + } + if let Some((dict, _)) = &mut op.inline { + for key in [&b"CS"[..], b"ColorSpace"] { + if let Some(Object::Name(n)) = dict.get(key) + && let Some(to) = renamed(b"ColorSpace", n) + { + dict.set(key.to_vec(), Object::Name(to)); + } + } + } + } + + if marked > 0 || in_text { + return Err(refuse("its content leaves marked content or a text object open")); + } + + // Patterns keep their place relative to the shapes they fill: each one the form selects is + // drawn from a copy that also carries the placement. Nested forms and patterns inside + // patterns keep their own parents, so only this stream's selections change. + let mut patterns: Vec<(Vec, Object)> = Vec::new(); + let mut pattern_bytes = MAX_FORM_BYTES; + if placement != Matrix::IDENTITY { + let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut moved: Renames = Vec::new(); + let mut n = 0u32; + for op in &mut ops { + if !(op.is("scn") || op.is("SCN")) { + continue; + } + let Some(i) = op.operands.len().checked_sub(1) else { continue }; + let Some(Object::Name(from)) = op.operands.get(i) else { continue }; + // A name the resources don't have selects nothing, inline or not. + let Some(entry) = table.get(from).cloned() else { continue }; + let to = match moved.iter().find(|(f, _)| f == from) { + Some((_, to)) => to.clone(), + None => { + if moved.len() >= MAX_MOVED_PATTERNS { + return Err(refuse("it paints with too many patterns")); + } + let copy = + moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?; + if let Object::Stream(s) = © { + pattern_bytes = + pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies exceed the editing budget"))?; + } + let fresh = loop { + n = n.saturating_add(1); + let candidate = format!("PCUgPat{n}").into_bytes(); + if table.get(&candidate).is_none() { + break candidate; + } + if n == u32::MAX { + return Err(refuse("too many resources")); + } + }; + // Reserved here; the caller replaces it with the copy's reference. + table.set(fresh.clone(), Object::Null); + patterns.push((fresh.clone(), copy)); + moved.push((from.clone(), fresh.clone())); + fresh + } + }; + if let Some(operand) = op.operands.get_mut(i) { + *operand = Object::Name(to); + } + } + if !patterns.is_empty() { + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } + } + + // A form in a layer (/OC) stays in it: its content is marked with the same membership. + let layer = match s.dict.get(b"OC") { + None => None, + Some(oc) => { + let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone()); + let name = match existing { + Some(k) => k, + None => { + let fresh = (1..=u32::MAX) + .map(|n| format!("PCUgOC{n}").into_bytes()) + .find(|c| props.get(c).is_none()) + .ok_or_else(|| refuse("too many resources"))?; + props.set(fresh.clone(), oc.clone()); + merged.set(b"Properties".to_vec(), Object::Dict(props)); + fresh + } + }; + Some(name) + } + }; + + let num = |v: f64| Object::Real(v); + let mut out = Vec::new(); + if let Some(name) = &layer { + out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())])); + } + out.push(Op::new("q", Vec::new())); + if let Some(m) = matrix { + out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); + } + let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| refuse("invalid form bounds"))?; + let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); + out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); + out.push(Op::new("W", Vec::new())); + out.push(Op::new("n", Vec::new())); + out.extend(ops); + // A form discards its unfinished current path when returning to its caller. + out.push(Op::new("n", Vec::new())); + // A form ends with the state it was drawn with (the Do saves and restores it). + out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); + out.push(Op::new("Q", Vec::new())); + if layer.is_some() { + out.push(Op::new("EMC", Vec::new())); + } + Ok((out, merged, patterns)) +} + +/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead +/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its +/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy. +fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option { + let resolved = doc.resolve(entry); + let (mut dict, stream) = match &*resolved { + Object::Stream(s) => (s.dict.clone(), Some(s)), + Object::Dict(d) => (d.clone(), None), + _ => return None, + }; + let own = match dict.get(b"Matrix") { + None => Matrix::IDENTITY, + Some(m) => { + let m = doc.resolve(m); + if m.as_array()?.len() != 6 { + return None; + } + let nums: Vec = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect(); + Matrix::from_operands(&nums)? + } + }; + let moved = own.then(&placement); + if !moved.0.iter().all(|v| v.is_finite()) { + return None; + } + dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect())); + match stream { + Some(s) if s.raw.len() > MAX_FORM_BYTES => None, + Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })), + None => Some(Object::Dict(dict)), + } +} + +/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending. +const MAX_NESTING: usize = 8; + +/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest +/// count as blending, so a file can't make the check run for ever. +const MAX_NESTED_READS: usize = 64; + +/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a +/// form drawing itself), the answer for each form with resources of its own (its answer doesn't +/// depend on who draws it), and how many reads it has made. +struct BlendWalk<'a> { + path: Vec, + known: HashMap, + reads: usize, + budget: &'a mut DecodeBudget, +} + +/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from +/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. +fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { + let (cat, i): (&[u8], usize) = match op.op.as_slice() { + b"Do" => (b"XObject", 0), + b"Tf" => (b"Font", 0), + b"scn" | b"SCN" => match op.operands.len().checked_sub(1) { + Some(i) => (b"Pattern", i), + None => return false, + }, + _ => return false, + }; + let Some(Object::Name(name)) = op.operands.get(i) else { return false }; + let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { + return false; + }; + let dict = match &*doc.resolve(&entry) { + Object::Stream(s) => s.dict.clone(), + Object::Dict(d) => d.clone(), + _ => return false, + }; + let drawn = match cat { + b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"), + b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"), + // A shading pattern has no content of its own. + _ => int_of(doc, &dict, b"PatternType") == Some(1), + }; + drawn && dict.get(b"Resources").is_none() +} + +/// Whether `ops` blend through a used graphics state, nested form or pattern. Unreadable, +/// recursive or over-budget nested content is treated as blending, so preparation refuses it. +fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool { + let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern")); + let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs)); + for op in ops { + if op.is("gs") && state_blends(op.name(0).and_then(|n| states.get(n))) { + return true; + } + let o = if op.is("Do") { + op.name(0).and_then(|n| xobjects.get(n)) + } else if op.is("scn") || op.is("SCN") { + op.operands.len().checked_sub(1).and_then(|i| op.name(i)).and_then(|n| patterns.get(n)) + } else { + continue; + }; + let Some(o) = o else { continue }; + let resolved = doc.resolve(o); + let nested = match &*resolved { + Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st, + Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st, + // A shading pattern. + Object::Stream(st) if !op.is("Do") => { + if state_blends(st.dict.get(b"ExtGState")) { + return true; + } + continue; + } + Object::Dict(d) if !op.is("Do") => { + if state_blends(d.get(b"ExtGState")) { + return true; + } + continue; + } + _ => continue, + }; + let r = o.as_ref(); + if let Some(known) = r.and_then(|r| walk.known.get(&r)) { + if *known { + return true; + } + continue; + } + if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS { + return true; + } + walk.reads += 1; + let Ok(data) = walk.budget.decode_strict(nested) else { return true }; + let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let has_own = own_resources.is_some(); + let own = own_resources.unwrap_or_else(|| resources.clone()); + let inner = parse(&data); + if inner.skipped > 0 { + return true; + } + if let Some(r) = r { + walk.path.push(r); + } + let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk); + if let Some(r) = r { + walk.path.retain(|x| *x != r); + if has_own { + walk.known.insert(r, blends); + } + } + if blends { + return true; + } + } + false +} + +/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency +/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses. +pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option { + let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?; + group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone()) +} + +/// Whether a group blending in `cs` composites as the page does: in the page group's own space +/// when it names one, else in DeviceRGB, the space viewers composite a page in on screen. +fn same_blend_space(doc: &Document, cs: &Object, page_space: Option<&Object>) -> bool { + let cs = doc.resolve(cs); + match page_space { + Some(page) => *cs == *page, + None => cs.as_name() == Some(b"DeviceRGB"), + } +} + +/// Whether `d`'s `key` is the name `want`, directly or through an indirect object (any value may +/// be one, ISO 32000-2 §7.3.10). +fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool { + d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want)) +} + +/// `d`'s integer `key`, directly or through an indirect object. +fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| doc.resolve(v).as_int()) +} + +/// Whether an ExtGState blends with something other than Normal. +fn blends(doc: &Document, gs: &Dict) -> bool { + let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false }; + let first = match &*bm { + Object::Array(a) => a.first().map(|x| doc.resolve(x)), + _ => Some(bm.clone()), + }; + !first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible"))) +} + +/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's +/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs` +/// changes only the parameters it sets. +pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool { + let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + // Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal. + let mut stack: Vec<[bool; 4]> = Vec::new(); + let mut now = [false; 4]; + for op in ops.iter().take(at) { + match op.op.as_slice() { + b"q" => stack.push(now), + b"Q" => now = stack.pop().unwrap_or([false; 4]), + b"gs" => { + let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else { + continue; + }; + let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0); + if let Some(v) = alpha(b"ca") { + now[0] = v; + } + if let Some(v) = alpha(b"CA") { + now[1] = v; + } + if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) { + now[2] = m.as_name() != Some(b"None"); + } + if gs.get(b"BM").is_some() { + now[3] = blends(doc, &gs); + } + } + _ => {} + } + } + now.iter().any(|v| *v) +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 83046bfbf7..98de481444 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -2,9 +2,10 @@ use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, - RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, - no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, - source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, + RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, + needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, + source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, + union, write_type3_glyphs, }; #[path = "text_regions/grouping.rs"] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7a9204a8c0..5d8075785e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -2,9 +2,9 @@ use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, - RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, - no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, - splice, type3_faces, type3_glyph, write_type3_glyphs, + RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, + needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, + source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs, }; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. @@ -104,9 +104,16 @@ impl FontResolver<'_> { } } +struct LegacyFace { + glyphs: Option, + metrics: Metrics, +} + struct PreparedCharacters { values: Vec, substituted: Option, + outlined_thai: std::collections::HashSet, + legacy_thai: HashMap, } struct CharacterPreparation<'a> { @@ -125,12 +132,73 @@ struct CharacterPreparation<'a> { impl CharacterPreparation<'_> { fn prepare(self) -> Result { let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let thai_choice = builtin_thai_choice(fonts); + let mut outlined_thai = std::collections::HashSet::new(); + let mut legacy_thai = HashMap::new(); let mut chars = Vec::new(); let mut substituted = None; let mut fallback = FallbackBuilder::new(max_glyphs); let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; for (range, index) in spans { let source = lines.get(index).unwrap_or(first); + let span = draft.get(range.clone()).unwrap_or_default(); + // Keep the exact source face. A different Thai face requires an explicit choice. + // Interface fallback fonts never enter a PDF. + let selected_resource = overrides.get(&index).and_then(|r| r.resource.as_deref()).or(style.resource.as_deref()); + let use_thai_outlines = if selected_resource == Some(thai_choice.as_str()) { + let face = pdfcraft_fonts::document_thai_font() + .ok_or_else(|| EditError::Invalid("the bundled Thai font is unavailable in this build; load a complete Thai font".into()))?; + if !pdfcraft_fonts::embedding::covers(face.bytes, span) { + return Err(EditError::Invalid(format!( + "{} cannot show every character in this selection; choose a font that covers the text", + face.family + ))); + } + substituted = Some(face.family.to_owned()); + true + } else { + selected_resource.is_none() && matching_thai_outline(resolver.doc, resolver.fonts, source, span) + }; + if use_thai_outlines { + substituted = pdfcraft_fonts::document_thai_font().map(|f| f.family.to_owned()); + } + let existing_thai = if !use_thai_outlines { + let chosen = match selected_resource { + None => Some(source.clone()), + Some(resource) => resolver.fonts.get(resource.as_bytes()).and_then(|f| resolver.doc.dict(f)).map(|font| { + let mut chosen = source.clone(); + chosen.font = resource.to_owned(); + chosen.base_font = Metrics::from_dict(resolver.doc, &font).base_font; + chosen + }), + }; + match chosen { + Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut legacy_thai)?, + None => None, + } + } else { + None + }; + let outline_name = if use_thai_outlines { + // A draft-only name prevents this choice from affecting another style span that + // explicitly selects the original resource. It is never saved into the PDF. + let mut name = format!("PCOutlineThai{index}"); + while resolver.metrics.contains_key(&name) { + name.push('_'); + } + if let Some(m) = resolver + .metrics + .get(&source.font) + .or_else(|| source.origin.state.font.as_ref().and_then(|(name, _)| resolver.metrics.get(name))) + .cloned() + { + resolver.metrics.insert(name.clone(), m); + } + outlined_thai.insert(name.clone()); + Some(name) + } else { + None + }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { if ch == '\n' { @@ -144,7 +212,11 @@ impl CharacterPreparation<'_> { } let text = ch.to_string(); let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); - let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + let name = if let Some(name) = &outline_name { + name.clone() + } else if let Some(name) = &existing_thai { + name.clone() + } else if let Some(name) = resolver.encodable_font(source, &text, requested)? { name } else if needs_type3(&text) { fallback.prepare(source, ch, &text, chars.len())? @@ -173,7 +245,7 @@ impl CharacterPreparation<'_> { } substituted = Some(written.family.to_owned()); } - Ok(PreparedCharacters { values: chars, substituted }) + Ok(PreparedCharacters { values: chars, substituted, outlined_thai, legacy_thai }) } } @@ -233,7 +305,16 @@ struct StyledLines<'a> { type StyledChar = (char, usize, String); type StyledRows = Vec>; type RowRanges = Vec>; -type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; +enum ShapedFont { + Existing(Vec<(String, Vec)>), + Program { bytes: Vec, family: String }, + ThaiOutlines, +} +struct ShapedText { + run: pdfcraft_fonts::source_shaping::ShapedRun, + font: ShapedFont, +} +type ShapedRunCache = HashMap<(usize, usize), ShapedText>; struct ShapedRows { runs: ShapedRunCache, @@ -243,6 +324,8 @@ struct ShapedRows { fn prepare_shaped_rows( doc: &Document, fonts: &Dict, + outlined_thai: &std::collections::HashSet, + legacy_thai: &HashMap, rows: &[Vec], kept: &[bool], advance: impl Fn(&StyledChar) -> f64, @@ -259,8 +342,8 @@ fn prepare_shaped_rows( let segment = row.get(at..end).unwrap_or_default(); advances.insert((row_index, at), segment.iter().map(&advance).sum::()); let text: String = segment.iter().map(|character| character.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { - runs.insert((row_index, at), (bytes, shaped, family)); + if let Some(shaped) = shape_source(doc, fonts, outlined_thai, legacy_thai, &item.2, &text)? { + runs.insert((row_index, at), shaped); } at = end; } @@ -268,16 +351,138 @@ fn prepare_shaped_rows( Ok(ShapedRows { runs, advances }) } -fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { +type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec)>>; + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result { let mut resources = HashMap::new(); - for (key, (bytes, shaped, family)) in runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - resources.insert(*key, resource); + for (key, shaped) in runs { + let glyphs = match &shaped.font { + ShapedFont::Existing(codes) => codes.clone(), + ShapedFont::ThaiOutlines => crate::added::thai_glyph_resources(doc, fonts, &shaped.run)?, + ShapedFont::Program { bytes, family } => { + let mappings: Vec<_> = shaped.run.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + (0..shaped.run.glyphs.len()) + .map(|i| { + let code = u16::try_from(i.saturating_add(1)).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + Ok((resource.clone(), code.to_be_bytes().to_vec())) + }) + .collect::, EditError>>()? + } + }; + resources.insert(*key, glyphs); } Ok(resources) } +/// A positioned glyph run, kept in one text object across Type 3 font shards. +struct ShapedRunDrawing<'a> { + doc: &'a Document, + fonts: &'a Dict, + shaped: &'a pdfcraft_fonts::source_shaping::ShapedRun, + resources: &'a [(String, Vec)], + text: &'a str, + matrix: [f64; 6], + size: f64, + thai: bool, +} + +impl ShapedRunDrawing<'_> { + /// Draw and return the run's advance in text space, including cluster-safe spacing. + fn draw(&self, block_ops: &mut Vec, state: &mut TextState) -> Result { + let Self { doc, fonts, shaped, resources, text, matrix: tm, size, thai } = *self; + let n = pdfcraft_content::num; + let (resource, _) = self.resources.first().ok_or_else(|| EditError::Invalid("shaped run has no glyphs".into()))?; + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(text))); + block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + state.font = Some((resource.clone(), size)); + // One text object for the run: each glyph is placed where shaping put it by a TJ + // adjustment from where the glyph before it ends (its advance in the embedded + // font's widths), and raised by the text rise for a mark set above or below + // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; + // ToUnicode and ActualText keep the logical text. + let per_unit = 1000.0 / size; + if !per_unit.is_finite() { + return Err(EditError::Invalid("the shaped text has no size".into())); + } + let mut shown_fonts = HashMap::new(); + for (name, _) in resources { + if !shown_fonts.contains_key(name) { + let font = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + shown_fonts.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + } + // Keep the original text settings. TJ compensates for the automatic PDF advance + // after each glyph, while shaping keeps marks attached to their cluster. + let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + // `pen`: where the next glyph would start, in thousandths of the font size. + let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); + let mut shown_glyphs: Vec = Vec::new(); + let mut cluster_spacing = 0.0; + let mut current_resource = resource.as_str(); + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let (name, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?; + let shown = shown_fonts.get(name).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + if name != current_resource { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + block_ops.push(Op::new("Tf", vec![Object::name(name), n(size)])); + current_resource = name; + } + if thai && !glyph.text.is_empty() { + // Apply spacing only after a whole cluster, keeping marks on their base. + extra += cluster_spacing; + cluster_spacing = char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + let target = (glyph.position[0] * size + extra) * per_unit; + let glyph_rise = rise + glyph.position[1] * size; + if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + if (glyph_rise - raised).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); + raised = glyph_rise; + } + } + if (target - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - target)); + } + shown_glyphs.push(Object::String(PdfString::literal(code.clone()))); + let cid = code.iter().fold(0_u32, |value, byte| value.saturating_mul(256).saturating_add(u32::from(*byte))); + pen = target + + if code.is_empty() { + 0.0 + } else { + shown.width(cid) * 1000.0 + (char_spacing + if shown.is_space(cid, code.len()) { word_spacing } else { 0.0 }) * per_unit + }; + if !thai { + extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + } + let end = (shaped.advance * size + extra + cluster_spacing) * per_unit; + if (end - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - end)); + } + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); + } + if (raised - rise).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(rise)])); + } + block_ops.push(Op::new("ET", vec![])); + (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); + extra += cluster_spacing; + block_ops.push(Op::new("EMC", vec![])); + Ok(shaped.advance * size + extra) + } +} + fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { let text: String = row.iter().map(|character| character.0).collect(); @@ -578,19 +783,220 @@ fn apply_range_styles<'a>( StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } } +/// Select one existing subset of the exact source face for a Thai span. Never replace its +/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. +fn legacy_thai_font( + doc: &Document, + fonts: &Dict, + source: &TextLine, + span: &str, + cache: &mut HashMap, +) -> Result, EditError> { + if !source.base_font.rsplit('+').next().unwrap_or(&source.base_font).starts_with("THSarabunPSK") + || !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + { + return Ok(None); + } + let family = pdfcraft_fonts::embedding::face_key(&source.base_font); + let mut candidates = vec![source.font.clone()]; + candidates.extend(fonts.iter().filter_map(|(name, font)| { + let font = doc.dict(font)?; + (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) + })); + for name in candidates.into_iter().take(128) { + if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(name) { + let Some(font) = fonts.get(entry.key().as_bytes()).and_then(|f| doc.dict(f)) else { continue }; + let metrics = Metrics::from_dict(doc, &font); + let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics)).ok().flatten(); + entry.insert(LegacyFace { glyphs, metrics }); + } + } + for row in span.split(['\n', '\r']).filter(|row| !row.is_empty()) { + shape_existing_thai(cache, &source.font, row)?; + } + Ok(Some(source.font.clone())) +} + +/// A visual row may use several subsets of the same exact face (often a separate subset for +/// punctuation). Shape Thai clusters intact, and retain each glyph's original PDF font/code. +fn shape_existing_thai(cache: &HashMap, primary: &str, text: &str) -> Result { + if text.len() > 262_144 { + return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into())); + } + let first = cache.get(primary).ok_or_else(|| EditError::Invalid("the Thai source face is missing".into()))?; + let family = pdfcraft_fonts::embedding::face_key(&first.metrics.base_font); + let mut candidates = vec![(primary, first)]; + candidates.extend( + cache + .iter() + .filter(|(name, face)| name.as_str() != primary && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family) + .map(|(name, face)| (name.as_str(), face)), + ); + // Stable source-first choice, then resource name; never depend on hash iteration order. + if let Some(tail) = candidates.get_mut(1..) { + tail.sort_by_key(|(name, _)| *name); + } + let thai = |c: char| matches!(u32::from(c), 0x0e01..=0x0e5b | 0xf700..=0xf71a); + let mut run = pdfcraft_fonts::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 }; + let mut resources = Vec::new(); + let mut chars = text.char_indices().peekable(); + while let Some((at, ch)) = chars.next() { + if thai(ch) { + let mut end = at + ch.len_utf8(); + while let Some(&(next, c)) = chars.peek() { + if !thai(c) { + break; + } + chars.next(); + end = next + c.len_utf8(); + } + let segment = text.get(at..end).ok_or_else(|| EditError::Invalid("invalid Thai character boundary".into()))?; + let mut selected = None; + let mut why = String::new(); + for (name, face) in &candidates { + if let Some(font) = &face.glyphs { + match font.shape(segment) { + Ok(shaped) => { + selected = Some((*name, shaped)); + break; + } + Err(e) => { + if why.is_empty() { + why = e; + } + } + } + } + } + let (name, shaped) = selected.ok_or_else(|| { + EditError::Invalid(format!( + "{} cannot keep this edit in the original face: {}; load the complete original font or explicitly choose a replacement", + first.metrics.base_font, + if why.is_empty() { "its source glyph program is incomplete" } else { &why } + )) + })?; + for (mut glyph, code) in shaped.shaped.glyphs.into_iter().zip(shaped.codes) { + glyph.position[0] += run.advance; + resources.push((name.to_owned(), code)); + run.glyphs.push(glyph); + } + run.advance += shaped.shaped.advance; + } else { + let logical = ch.to_string(); + let found = candidates.iter().find_map(|(name, face)| { + let code = face.metrics.encode(&logical)?; + let cid = face.metrics.codes(&code).first()?.0; + Some((*name, code, face.metrics.width(cid))) + }); + let (name, code, width) = if let Some(found) = found { + found + } else if ch == ' ' { + // The source subset may omit spaces. Use a gap measured from a matching + // document subset, then the face's existing text-space fallback. + let width = candidates + .iter() + .find_map(|(_, f)| { + let code = f.metrics.encode(" ")?; + Some(f.metrics.width(f.metrics.codes(&code).first()?.0)) + }) + .unwrap_or(0.25); + (primary, Vec::new(), width) + } else { + return Err(EditError::Invalid(format!( + "{} has no source glyph for {ch} (U+{:04X}); load the complete original face or explicitly select a replacement", + first.metrics.base_font, + u32::from(ch) + ))); + }; + run.glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { id: Default::default(), position: [run.advance, 0.0], text: logical }); + resources.push((name.to_owned(), code)); + run.advance += width; + } + if run.glyphs.len() > 65_534 || !run.advance.is_finite() { + return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into())); + } + } + Ok(ShapedText { run, font: ShapedFont::Existing(resources) }) +} + +/// Whether the approved Thai face can replace this source, whose embedded subset cannot +/// shape the replacement. A usable complete source program always takes precedence. +fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool { + let Some(face) = pdfcraft_fonts::document_thai_font() else { return false }; + if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + || !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face)) + || !pdfcraft_fonts::embedding::covers(face.bytes, span) + { + return false; + } + // A registered complete matching program also completes the source subset. + // Do not replace it with bundled outlines merely because the original resource is a subset. + let family = pdfcraft_fonts::embedding::face_key(&source.base_font); + !fonts.iter().filter_map(|(_, font)| doc.dict(font)).any(|font| { + pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family + && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { + pdfcraft_fonts::embedding::validate(&bytes).is_ok() + && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) + && pdfcraft_fonts::embedding::covers(&bytes, span) + && span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + }) + }) +} + +/// Use the same Thai script selection and mark/cluster positioning as Add Text, including +/// rows beginning with Latin or digits (guessing from their first character would select Latin). +fn shape_thai_row(text: &str) -> Result { + if text.len() > 262_144 || text.contains(['\n', '\r']) { + return Err(EditError::Invalid("shape one bounded text row at a time".into())); + } + let clusters = + pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?; + let mut glyphs = Vec::new(); + let mut advance = 0.0; + for cluster in clusters { + for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() { + if glyphs.len() >= 65_534 { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { + id: id.into(), + position: [advance + x, y], + text: if i == 0 { cluster.text.clone() } else { String::new() }, + }); + } + advance += cluster.advance; + } + if !advance.is_finite() { + return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); + } + Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance }) +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, fonts: &Dict, + outlined_thai: &std::collections::HashSet, + legacy_thai: &HashMap, name: &str, text: &str, -) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { +) -> Result, EditError> { + if outlined_thai.contains(name) { + let shaped = shape_thai_row(text)?; + return Ok(Some(ShapedText { run: shaped, font: ShapedFont::ThaiOutlines })); + } if !pdfcraft_fonts::source_shaping::required(text) { return Ok(None); } let source = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; - let family = Metrics::from_dict(doc, &source).base_font; + let metrics = Metrics::from_dict(doc, &source); + if metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font).starts_with("THSarabunPSK") + && text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) + { + return shape_existing_thai(legacy_thai, name, text).map(Some); + } + let family = metrics.base_font; let mut candidates = vec![source]; candidates.extend( fonts @@ -603,10 +1009,17 @@ fn shape_source( && pdfcraft_fonts::embedding::covers(&bytes, text) && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) { - return Ok(Some((bytes, shaped, family))); + return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } })); } } - Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) + let fallback = pdfcraft_fonts::document_thai_font() + .filter(|_| text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))) + .map(|face| format!("; or select {} in the document-font menu to replace it", face.family)) + .unwrap_or_default(); + Err(EditError::Invalid(format!( + "complex text in {} needs its complete source TrueType/OpenType font; use Load font to keep it{fallback}", + family.rsplit('+').next().unwrap_or(&family) + ))) } /// For each output row (a range of the draft's characters), whether it is exactly the source row @@ -889,6 +1302,8 @@ pub(crate) fn rewrite_styled_block( max_glyphs, } .prepare()?; + let outlined_thai = prepared.outlined_thai; + let legacy_thai = prepared.legacy_thai; let chars = prepared.values; let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { @@ -939,7 +1354,8 @@ pub(crate) fn rewrite_styled_block( } let segment = items.get(at..end).unwrap_or_default(); let string: String = segment.iter().map(|v| v.0).collect(); - if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + if let Some(shaped) = shape_source(doc, &fonts, &outlined_thai, &legacy_thai, &item.2, &string)? { + let shaped = &shaped.run; let source = lines.get(item.1).unwrap_or(first); let scale = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); @@ -990,7 +1406,7 @@ pub(crate) fn rewrite_styled_block( let at = if split { start } else { o.bt_op }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; - let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let shaped = prepare_shaped_rows(doc, &fonts, &outlined_thai, &legacy_thai, &rows, &kept, advance)?; let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; @@ -1100,67 +1516,19 @@ pub(crate) fn rewrite_styled_block( // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); block_ops.extend(state.paint_ops(drawn_from, run_ctm)); - if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { - let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - let mut properties = Dict::new(); - properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); - block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); - state.font = Some((resource.clone(), size_of(source, item.1))); - // One text object for the run: each glyph is placed where shaping put it by a TJ - // adjustment from where the glyph before it ends (its advance in the embedded - // font's widths), and raised by the text rise for a mark set above or below - // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; - // ToUnicode and ActualText keep the logical text. - let size = size_of(source, item.1); - let per_unit = 1000.0 / size; - if !per_unit.is_finite() { - return Err(EditError::Invalid("the shaped text has no size".into())); - } - let shown = fonts - .get(resource.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .map(|d| Metrics::from_dict(doc, &d)) - .ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - // Spacing is part of each glyph's position, so the run itself has none. - let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); - state.char_spacing = 0.0; - state.word_spacing = 0.0; - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // `pen`: where the next glyph would start, in thousandths of the font size. - let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); - let mut shown_glyphs: Vec = Vec::new(); - for (i, glyph) in shaped.glyphs.iter().enumerate() { - let target = (glyph.position[0] * size + extra) * per_unit; - let glyph_rise = rise + glyph.position[1] * size; - if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); - } - if (glyph_rise - raised).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); - raised = glyph_rise; - } - } - if (target - pen).abs() > 1e-6 { - shown_glyphs.push(n(pen - target)); - } - let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; - shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec()))); - pen = target + shown.width(u32::from(code)) * 1000.0; - extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; - } - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); - } - if (raised - rise).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(rise)])); - } - block_ops.push(Op::new("ET", vec![])); - (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); - block_ops.push(Op::new("EMC", vec![])); - segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; + if let Some(shaped) = shaped_runs.get(&(row_index, pos)) { + let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let drawing = ShapedRunDrawing { + doc, + fonts: &fonts, + shaped: &shaped.run, + resources, + text: &string, + matrix: tm, + size: size_of(source, item.1), + thai: string.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)), + }; + segment_width = drawing.draw(&mut block_ops, &mut state)? * state.scale * source.origin.k; } else { block_ops.push(Op::new("BT", vec![])); block_ops.extend(state.ops()); diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 279dc8d563..f3b0d75799 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -255,6 +255,20 @@ impl Document { pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) } + /// Whether this page has grouped source text that can be prepared without changing its appearance. + /// Used once when entering the existing-text tool, before queuing an undoable preparation. + pub fn can_prepare_text(&self, page: usize) -> bool { + self.text_preparation(page).is_ok_and(|p| p.expanded > 0) + } + + /// Preview preparation, including skipped groups and character-map repairs, without editing. + pub fn text_preparation(&self, page: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document is not editable")?; + crate::guard(|| pdfcraft_edit::text::forms::prepare(&mut editor.cos.clone(), page)) + .map_err(|e| format!("grouped text could not be prepared safely ({e})"))? + .map_err(|e| e.to_string()) + } + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). pub fn text_blocks(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 1bcf24108f..f1a99a2387 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -466,6 +466,10 @@ pub enum Edit { /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. style: pdfcraft_edit::BlockStyle, }, + /// Expose supported grouped source text on one page for direct editing. + PrepareText { + page: usize, + }, /// An explicit open-licensed full font program, completing subsets or selected by a user. RegisterTextFont { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 615ac0fea5..b66ef713f2 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -111,6 +111,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete document JavaScript", "Edit document JavaScript", "Edit text", + "Prepare grouped text", "Load document font", "Move object", "Move objects", @@ -269,6 +270,7 @@ impl Edit { Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::PrepareText { .. } => Label::of("Prepare grouped text"), Edit::RegisterTextFont { .. } => Label::of("Load document font"), Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), Edit::EditPageImage { change, .. } => Label::of(match change { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 4719de302f..6368e9097c 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -148,6 +148,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::PrepareText { .. } | Edit::RegisterTextFont { .. } | Edit::EditPageImage { .. } | Edit::Flatten { .. } @@ -726,6 +727,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } + Edit::PrepareText { page } => { + let result = pdfcraft_edit::text::forms::prepare(doc, *page)?; + if result.expanded == 0 { + return Err(EditError::Invalid( + result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()), + )); + } + } Edit::RegisterTextFont { page, bytes } => { pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ce51df5e61..4997f64efe 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -81,6 +81,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::MoveObjects { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } + | Edit::PrepareText { .. } | Edit::EditPageImage { .. } | Edit::EnhanceScan { .. } | Edit::AddText { .. } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9004d88205..7959d2ae3f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -617,6 +617,8 @@ pub struct DocView { /// Edit text: the lines per page (with the document generation they were read at), and the /// line being edited. pub(crate) edit_lines: HashMap)>, + /// Pages checked for grouped source text during this Edit text activation. + pub(crate) prepared_text_pages: std::collections::HashSet, pub line_editor: Option, /// The paragraph editor whose edit was just queued. It opens again with its typing if the /// edit fails, as a refused form field does. @@ -1006,6 +1008,7 @@ impl DocView { organize_range: Default::default(), pending_edit: None, edit_lines: HashMap::new(), + prepared_text_pages: Default::default(), line_editor: None, applied_editor: None, pending_text_open: None, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e1c9719668..34e1362202 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -66,6 +66,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + if tool != QuickTool::EditText { + view.prepared_text_pages.clear(); + } else if can_modify && view.pending_edit.is_none() && view.line_editor.is_none() && view.prepared_text_pages.insert(i) && doc.can_prepare_text(i) + { + view.pending_edit = Some(pdfcraft_engine::Edit::PrepareText { page: i }); + ui.ctx().request_repaint(); + } let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 1946519203..19331230a7 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -68,7 +68,9 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 { /// One TextEdit lays out all source runs; its caret and selection span the whole region. pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap { - let programs = doc.text_editor_fonts(page); + // Bundled replacement selectors are menu choices, never source resource names. Installing + // an unused choice would make even a standard-font editor wait a frame for its font atlas. + let programs: Vec<_> = doc.text_editor_fonts(page).into_iter().filter(|font| !font.choice_only).collect(); let mut definitions = ctx.fonts(|f| f.definitions().clone()); // Only the current editor's document fonts live in the atlas. Never accumulate the // programs of previously opened files or add them to the interface's default families. diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 426058f7b7..a8966ff207 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -1,5 +1,16 @@ # Editing existing text and source subsets +Entering **Edit text and images** prepares supported grouped Form XObjects on the visible +page in an undoable **Prepare grouped text** step. The group's original operators, clip, +placement and resources become page content; shared forms on other pages stay unchanged. +Headless callers use `text_prepare`, then list `text_paragraphs` and call `text_edit`. +This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box. +Preparation refuses knockout/blended groups, incompatible colour spaces, tagged forms, +recursive forms, unfinished paths and unreadable content. Decoding is capped at 16 MiB, +64 placements and eight expansion levels per page. Unsupported groups remain intact. +A narrowly fingerprinted legacy THSarabunPSK exporter map can also be repaired in copied page +fonts; the repair log records both preparation and character-map recovery. + Click a text region in **Edit a PDF → Edit text and images**. One editor contains its source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory; source font programs, colour, size and baseline anchors stay intact. A textless preview @@ -44,6 +55,30 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +**Keep source fonts** retains the existing Thai family, weight and point size. For legacy +THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's +Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the +saved text refers to the original PDF font resources. Several subsets of the same exact +face can supply Thai and punctuation glyphs; Regular and Bold stay separate. Source font +programs are never modified or replaced. Text settings, including character/word spacing, +horizontal scale, rise, color and paint mode, remain in the PDF text state. + +A source subset that lacks a needed contextual glyph produces an error asking for the +complete original face or an explicit replacement. Cross-family replacement requires an +explicit choice. The document-font menu offers **Noto Sans Thai**: pass its `text_fonts` +resource (normally `builtin:thai`) as `text_edit.font_resource` or a range's `font_resource`. This replacement +requires the optional craft-fonts Thai input and currently uses Regular. A matching Noto +subset can still be completed by that same bundled family. + +The bundled variable Thai face is written as bounded Type 3 glyph outlines, with shaped +vowel/tone positions and logical Unicode text, rather than embedded as a static font. +Saved paragraphs can be reopened and edited again without installing a font. Modern Thai +shaping uses the source's GPOS mark positioning; GSUB substitution is optional, as specified by +[the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai). +Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS. +Mixed contextual style runs and dictionary word segmentation remain limited. No font binary +or release pin changes are needed. + When mixed-style text needs outline fallback, characters using the same actual fallback face share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same font and source style are written as one run. Latin characters that the source font can encode diff --git a/docs/roadmap.md b/docs/roadmap.md index 736d53bb7a..28afeea246 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation; estimates unchanged) + > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) @@ -239,7 +241,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -312,6 +314,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. | | 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 10b040e09f..54aa9561b5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1894,9 +1894,9 @@ tier = "P0" milestone = "M7" status = "partial" commands = ["edit.edit_text"] -tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" +tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete"] +notes = "Supported grouped Forms are prepared on entering Edit text, or via text_prepare, so their existing source glyphs can be edited directly. Unsafe groups remain intact. Narrow legacy THSarabunPSK character-map recovery is recorded. Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets, preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" From 7634ac806457419c5a870da5f7a9ef9da709396e Mon Sep 17 00:00:00 2001 From: xullul Date: Sat, 7 Nov 2026 15:59:38 +0700 Subject: [PATCH 03/21] 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" From a453112151c6ef5dc44fdcb80573dcb9c09586bd Mon Sep 17 00:00:00 2001 From: xullul Date: Sat, 7 Nov 2026 17:00:48 +0700 Subject: [PATCH 04/21] M7: read each grouped form once and check PUA alternate meanings Change: - Leave a form found to be artwork, or refused for a reason in the form itself, out of later preparation passes, so a page with many logos or unsupported groups no longer reaches the 64-placement limit and prepares nothing. ungroup_ops now marks each refusal as intrinsic or contextual; contextual ones (translucency, the resources it would merge into, pattern placement, the decode budget) are re-evaluated where the form appears, so a form refused in one place is still prepared where a parent draws it. - Refuse a page-level Do with an operand too many for that placement only: the form found by its first operand may not be the one drawn. Check a form's own operand counts before the nested walk, as an intrinsic refusal. - Use the Windows PUA alternates only when every kept alternate whose code the PDF's map names a Thai character names that alternate's base character, so alternates in another order that the map reveals are refused. Chores: - Say that the font program confirms each recovered glyph's advance class, not its identity, and document the alternate check. Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit 468, 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. --- crates/edit/src/tests.rs | 74 ++++++++++++++++++++++++++++ crates/edit/src/text/forms.rs | 35 ++++++++++--- crates/edit/src/text/forms/expand.rs | 67 +++++++++++++++---------- crates/fonts/src/pdf/thai.rs | 34 +++++++++++-- docs/editing-existing-text.md | 3 +- 5 files changed, 176 insertions(+), 37 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e425d00328..d3c0e4fdcd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10947,6 +10947,80 @@ fn grouped_image_artwork_is_not_prepared() { assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); } +/// Artwork and groups refused for what they are (knockout groups, content with an operand too +/// many) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text +/// group are 41 reads, within the 64-placement limit, where reading either again on the second +/// pass would pass it and prepare nothing. +#[test] +fn artwork_and_refused_groups_count_once_against_the_placement_limit() { + for knockout in [true, false] { + let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET 0 /Im Do" }; + let mut forms: Vec<(String, String)> = (1..=10).map(|i| (format!("Logo{i}"), "q 10 0 0 10 0 0 cm /Im Do Q".to_string())).collect(); + forms.extend((1..=30).map(|i| (format!("Refused{i}"), refused_body.to_string()))); + forms.push(("Body".into(), "BT /F1 14 Tf 40 300 Td (body) Tj ET".into())); + let content: String = forms.iter().map(|(name, _)| format!("/{name} Do\n")).collect(); + let mut doc = page_with_forms(&content, &forms); + // Forms are objects 7 onwards, in order: the refused groups follow the 10 logos. + for i in 0..30u32 { + let r = pdfcraft_cos::ObjRef::new(7 + 10 + i, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + if knockout { + let group = build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + s.dict.set(b"Group".to_vec(), Object::Dict(group)); + } + doc.set(r, Object::Stream(s)); + } + let result = text::forms::prepare(&mut doc, 0).unwrap_or_else(|e| panic!("knockout {knockout}: each group is read once ({e})")); + assert_eq!(result.expanded, 1); + assert_eq!(result.refused.len(), 30, "{:?}", result.refused); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "body")); + } +} + +/// A page-level `Do` with an operand too many is left as it is and settles nothing: the form +/// found by its first operand may not be the one the renderer draws (its last). Here `/A /B Do` +/// draws the knockout group B, and A, a text group, is still prepared where Parent draws it. +#[test] +fn a_page_do_with_an_operand_too_many_settles_no_form() { + let forms = [ + ("A".to_string(), "BT /F1 14 Tf 40 500 Td (alpha) Tj ET".to_string()), + ("B".to_string(), "BT /F1 14 Tf 40 400 Td (beta) Tj ET".to_string()), + ("Parent".to_string(), "/A Do".to_string()), + ]; + let mut doc = page_with_forms("/A /B Do\n/Parent Do", &forms); + let r = pdfcraft_cos::ObjRef::new(8, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + let group = build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + s.dict.set(b"Group".to_vec(), Object::Dict(group)); + doc.set(r, Object::Stream(s)); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 2, "Parent, then the A it draws: {:?}", result.refused); + assert!(result.refused.iter().any(|r| r.contains("an operand too many")), "{:?}", result.refused); +} + +/// A refusal that comes from where a form is drawn is not remembered for the form: a text group +/// refused where it is drawn translucent is still prepared where an expanded parent draws it +/// opaque, on the next pass. +#[test] +fn a_placement_refusal_does_not_settle_the_form_for_later_passes() { + let resources = "<< /Font << /F1 5 0 R >> /ExtGState << /Alpha << /ca 0.5 >> >> /XObject << /Shared 6 0 R /Parent 7 0 R >> >>"; + let form = |extra: &str, body: &str| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {extra} /Resources {resources}"), body); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", "q /Alpha gs /Shared Do Q /Parent Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + form("/Group << /S /Transparency >>", "BT /F1 14 Tf 40 500 Td (shared) Tj ET"), + form("", "/Shared Do"), + ]; + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 2, "Parent, then the Shared it draws opaque: {:?}", result.refused); + assert!(result.refused.iter().any(|r| r.starts_with("Shared: ") && r.contains("translucent")), "{:?}", result.refused); + assert_eq!(text::text_blocks(&doc, 0).unwrap().iter().filter(|b| b.text == "shared").count(), 1, "only the opaque one is the page's own text"); +} + /// 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() { diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index dff7440145..da46a16d91 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -32,6 +32,11 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result pending_path = true, @@ -59,23 +65,33 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result {} } let Some(image) = images.get(&at) else { continue }; - visits = visits.saturating_add(1); - if visits > 64 { - return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + if image.object.is_some_and(|o| settled.contains(&o)) { + continue; } if pending_path || in_text { result.refused.push(format!("{}: the group is drawn inside an unfinished path or text object", image.name)); continue; } + // The renderer draws a `Do`'s last operand; `image` was found by its first. With more + // than one they can be different forms, so this placement is left as it is. + if op.operands.len() != 1 { + result.refused.push(format!("{}: the group is drawn with an operand too many", image.name)); + continue; + } + visits = visits.saturating_add(1); + if visits > 64 { + return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + } let translucent = expand::translucent_at(&next, &merged, &ops, at); - // The resource name exactly as the `Do` writes it: a name need not be UTF-8, and - // `image.name` is only its display form. + // The resource name exactly as the `Do` writes it (its only operand): 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 { + settled.extend(image.object); continue; } if pass == MAX_LEVELS { @@ -96,10 +112,17 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result { + result.refused.push(format!("{}: {}", image.name, refusal.error)); + if refusal.intrinsic { + refused_here.extend(image.object); + } } - Err(error) => result.refused.push(format!("{}: {error}", image.name)), } } + settled.extend(refused_here.difference(&expanded_here).copied()); if replacements.is_empty() { break; } diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs index bf01e201d1..54495900cc 100644 --- a/crates/edit/src/text/forms/expand.rs +++ b/crates/edit/src/text/forms/expand.rs @@ -59,6 +59,16 @@ pub(crate) struct Ungrouped { pub(crate) shows_text: bool, } +/// Why [`ungroup_ops`] left a placement grouped. +pub(crate) struct Refusal { + pub(crate) error: EditError, + /// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every + /// placement of it is refused the same way. Otherwise something about this placement or the + /// page so far is: its translucency or matrix, the resources it would merge into (which grow + /// as other groups are prepared), or the decode budget left. + pub(crate) intrinsic: 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 /// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). @@ -72,8 +82,12 @@ pub(crate) fn ungroup_ops( page_space: Option<&Object>, placement: Matrix, budget: &mut DecodeBudget, -) -> Result { - let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); +) -> Result { + let refusal = |why: &str, intrinsic: bool| Refusal { error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), intrinsic }; + // Something about this placement or the page so far is the reason. + let refuse = |why: &str| refusal(why, false); + // The form itself is the reason: every placement of it is refused the same way. + let invalid = |why: &str| refusal(why, true); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; @@ -88,32 +102,32 @@ pub(crate) fn ungroup_ops( let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); (v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) }; - let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?; + let bbox = numbers(b"BBox", 4).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?; let matrix = match s.dict.get(b"Matrix") { None => None, Some(_) => Some(Matrix( - <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?) - .map_err(|_| refuse("its matrix is unreadable"))?, + <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| invalid("its matrix is unreadable"))?) + .map_err(|_| invalid("its matrix is unreadable"))?, )), }; let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); if s.dict.get(b"Resources").is_some() && own.is_none() { - return Err(refuse("its resources cannot be read")); + return Err(invalid("its resources cannot be read")); } // A form without its own resources uses the ones it is drawn with (PDF 1.1). let form_res = own.clone().unwrap_or_else(|| resources.clone()); // A tagged form's content is a structure element's: drawn inline it would lose that link. if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() { - return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags")); + return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags")); } let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency")); if s.dict.get(b"Group").is_some() && group.is_none() { - return Err(refuse("its group dictionary cannot be read")); + return Err(invalid("its group dictionary cannot be read")); } if let Some(group) = &group { if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { - return Err(refuse("it is a knockout transparency group")); + return Err(invalid("it is a knockout transparency group")); } if translucent { return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); @@ -123,7 +137,7 @@ pub(crate) fn ungroup_ops( if let Some(cs) = group.get(b"CS") && !same_blend_space(doc, cs, page_space) { - return Err(refuse("it is a transparency group that blends in a colour space of its own")); + return Err(invalid("it is a transparency group that blends in a colour space of its own")); } } @@ -131,9 +145,17 @@ pub(crate) fn ungroup_ops( let data = budget.decode_strict(s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; let parsed = parse(&data); if parsed.skipped > 0 { - return Err(refuse("its content has parts that can't be read")); + return Err(invalid("its content has parts that can't be read")); } let mut ops = parsed.ops; + // A name is renamed at a fixed 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. Checked before the nested + // walk, which can only call it unverifiable. + if ops.iter().any(|op| takes_operands(&op.op).is_some_and(|n| op.operands.len() > n)) { + return Err(invalid("its content gives an operator more operands than it takes")); + } 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. @@ -151,7 +173,7 @@ pub(crate) fn ungroup_ops( for cat in CATEGORIES { let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; if theirs.len() > 4096 { - return Err(refuse("too many resources")); + return Err(invalid("too many resources")); } let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); if mine.len() > 4096 { @@ -203,20 +225,13 @@ pub(crate) fn ungroup_ops( for op in &mut ops { match op.op.as_slice() { b"BDC" | b"BMC" => marked = marked.saturating_add(1), - b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?, - b"BT" if in_text => return Err(refuse("its content begins a text object inside another")), + b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?, + b"BT" if in_text => return Err(invalid("its content begins a text object inside another")), b"BT" => in_text = true, - b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")), + b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")), 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() { @@ -232,13 +247,13 @@ pub(crate) fn ungroup_ops( b"BDC" | b"DP" => Some((&b"Properties"[..], 1)), b"q" => { if depth >= 4096 { - return Err(refuse("graphics state is too deeply nested")); + return Err(invalid("graphics state is too deeply nested")); } depth += 1; None } b"Q" => { - depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?; + depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?; None } _ => None, @@ -262,7 +277,7 @@ pub(crate) fn ungroup_ops( } if marked > 0 || in_text { - return Err(refuse("its content leaves marked content or a text object open")); + return Err(invalid("its content leaves marked content or a text object open")); } // Patterns keep their place relative to the shapes they fill: each one the form selects is @@ -351,7 +366,7 @@ pub(crate) fn ungroup_ops( if let Some(m) = matrix { out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); } - let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| refuse("invalid form bounds"))?; + let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| invalid("invalid form bounds"))?; let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); out.push(Op::new("W", Vec::new())); diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 8596d58734..0f950afc6d 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -120,7 +120,8 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option). +const PUA_BASES: [char; 27] = ['ฐ', 'ิ', 'ี', 'ึ', 'ื', '่', '้', '๊', '๋', '์', '่', '้', '๊', '๋', '์', 'ญ', 'ั', 'ํ', '็', '่', '้', '๊', '๋', '์', 'ุ', 'ู', 'ฺ']; + /// 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 { @@ -236,17 +242,30 @@ pub(super) fn composite_glyph_maps( 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))); + // Where the PDF says what an alternate's code is (an intact ToUnicode gives the character it + // stands for), it must be that alternate's base character: two alternates of the same kind + // in another order pass the advance check, but not this one. + let code_of = |gid: u32| match &cid_map { + None => u16::try_from(gid).ok(), + Some(_) => codes.get(&gid).copied(), + }; + let meanings_agree = kept.iter().all(|&(index, gid)| { + let stated = code_of(gid).and_then(|code| metrics.text_of(u32::from(code))).and_then(|t| only_char(&t)); + stated.filter(|c| matches!(u32::from(*c), 0x0e01..=0x0e5b)).is_none_or(|c| PUA_BASES.get(index) == Some(&c)) + }); if family.starts_with("THSarabunPSK") && unicode.get(&0x0e01) == Some(&214) && unicode.get(&0x0e2d) == Some(&258) && (alternate_meanings || unicode.contains_key(&0xf70a)) && in_order + && meanings_agree { for (index, gid) in kept { - if let Some(code) = match &cid_map { + let code = match &cid_map { None => u16::try_from(gid).ok(), Some(_) => codes.get(&gid).copied(), - } { + }; + if let Some(code) = code { unicode.insert(0xf700 + index as u32, gid); codes.entry(gid).or_insert(code); } @@ -555,13 +574,14 @@ mod tests { #[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 alternates_with = |program: &[u8], unicode: &[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 alternates = |program: &[u8]| alternates_with(program, unicode); let ordered = alternates(&thai_ordered_program()); assert!(ordered.contains(&(0xf70a, 352)) && ordered.contains(&(0xf710, 358)), "{ordered:?}"); let mut misplaced = thai_ordered_program(); @@ -572,6 +592,12 @@ mod tests { drop_glyph(&mut dropped, 353); let kept = alternates(&dropped); assert!(kept.contains(&(0xf70a, 352)) && !kept.iter().any(|(_, g)| *g == 353), "{kept:?}"); + // Two marks of the same kind (both zero-advance) in another order: the PDF's own map says + // glyph 343 is ี and 344 is ิ, the reverse of U+F701 and U+F702. None is used. + let stated = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e35> <0158> <0e34> <0160> <0e48> <0161> <0e49> endbfchar"; + assert!(alternates_with(&thai_ordered_program(), stated).is_empty(), "a stated meaning out of the PUA order withdraws them all"); + let agreeing = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e34> <0158> <0e35> <0160> <0e48> <0161> <0e49> endbfchar"; + assert!(alternates_with(&thai_ordered_program(), agreeing).contains(&(0xf701, 343))); } #[test] diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index b7716cf4d9..6127f9a234 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -86,7 +86,8 @@ selects for Thai, not just a GPOS table); GSUB substitution is optional, as spec [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; 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 +consonant forms, an advance) and, where the PDF's own map names its character, the character +that alternate stands for. 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 From 4b2a15101288029197e1c7dae1bfac0c6dc137a2 Mon Sep 17 00:00:00 2001 From: xullul Date: Sat, 7 Nov 2026 18:34:05 +0700 Subject: [PATCH 05/21] M7: count each grouped placement once across preparation passes Change: - Read and count only the placements a preparation pass newly inlines. The others were read on an earlier pass in the context they still have, so a group refused where it is drawn (translucent, for example) is no longer counted again on every pass: 33 such placements beside one text group used to reach the 64-placement limit on the second pass and prepare nothing. A form refused in one place is still read where an expanded parent draws it anew. If the page doesn't parse back to the operators written, every placement is read again, as before. - Read a group refused because its resources needed renaming again, without counting it, once names merged in for other groups make the renaming unnecessary (checked from its resource dictionaries alone, without decoding its content), and drop its earlier refusal when it is then prepared. Validation: edit 470 and the 2 automation text_prepare tests passed; formatting, strict Clippy for pdfcraft-edit, layers and assets passed. The wasm check was not run locally (the wasm32 target is not installed), nor were the engine suite and the workspace sweep. Each new regression failed with its fix disabled. --- crates/edit/src/tests.rs | 54 +++++++++++++++++++ crates/edit/src/text/forms.rs | 78 ++++++++++++++++++++++++---- crates/edit/src/text/forms/expand.rs | 28 +++++++++- 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d3c0e4fdcd..5ae27517d3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -11021,6 +11021,60 @@ fn a_placement_refusal_does_not_settle_the_form_for_later_passes() { assert_eq!(text::text_blocks(&doc, 0).unwrap().iter().filter(|b| b.text == "shared").count(), 1, "only the opaque one is the page's own text"); } +/// A placement refused where it is drawn is read once, not again on every pass, while the same +/// form drawn anew by an expanded parent is still read in its own place: 33 translucent +/// placements of a text group and a parent drawing it opaque are 35 reads, within the +/// 64-placement limit, where reading the 33 again on the second pass would pass it and prepare +/// nothing. +#[test] +fn a_group_refused_where_it_is_drawn_counts_once_against_the_placement_limit() { + let resources = "<< /Font << /F1 5 0 R >> /ExtGState << /Alpha << /ca 0.5 >> >> /XObject << /Shared 6 0 R /Parent 7 0 R >> >>"; + let form = |extra: &str, body: &str| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {extra} /Resources {resources}"), body); + let content = format!("{}/Parent Do", "q /Alpha gs /Shared Do Q\n".repeat(33)); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", &content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + form("/Group << /S /Transparency >>", "BT /F1 14 Tf 40 500 Td (shared) Tj ET"), + form("", "/Shared Do"), + ]; + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + let result = text::forms::prepare(&mut doc, 0).unwrap_or_else(|e| panic!("each placement is read once ({e})")); + assert_eq!(result.expanded, 2, "Parent, then the Shared it draws opaque: {:?}", result.refused); + assert!(result.refused.iter().any(|r| r.starts_with("Shared: ") && r.contains("translucent")), "{:?}", result.refused); + let drawn = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.iter().filter(|op| op.is("Do")).count(); + assert_eq!(drawn, 33, "the translucent placements stay grouped"); +} + +/// A group refused because its resources would need renaming (it draws a form that looks them +/// up by name) is prepared on a later pass once another group's merge has given the page those +/// very names. A lists the child as `/PCUg1`; B lists it as `/Other`, which merging B names +/// `/PCUg1` on the page. Its first refusal is then no longer reported. +#[test] +fn a_group_refused_for_renaming_is_prepared_once_the_page_has_its_names() { + let form = |resources: &str, body: &str| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), 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 << /A 6 0 R /B 7 0 R >> >> /Contents 4 0 R >>" + .to_string(), + stream("", "/A Do\n/B Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + form("<< /Font << /F1 5 0 R >> /XObject << /PCUg1 8 0 R >> >>", "BT /F1 14 Tf 40 500 Td (alpha) Tj ET /PCUg1 Do"), + form("<< /Font << /F1 5 0 R >> /XObject << /Other 8 0 R >> >>", "BT /F1 14 Tf 40 400 Td (beta) Tj ET"), + // The child has no resources of its own: it looks names up in whatever draws it. + stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", "0 0 10 10 re f"), + ]; + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 2, "B, then A once the page names the child as A does: {:?}", result.refused); + assert!(result.refused.is_empty(), "A was prepared after all: {:?}", result.refused); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert!(["alpha", "beta"].iter().all(|t| blocks.iter().any(|b| b.text == *t)), "{blocks:?}"); +} + /// 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() { diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index da46a16d91..fcc7a31e13 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -24,6 +24,17 @@ pub struct Preparation { /// grouped and is reported in [`Preparation::refused`]. const MAX_LEVELS: usize = 8; +/// What one pass of [`prepare`] inlined, as positions in the next pass's parse of the page. +struct Inlined { + /// How many operators that parse has; with any other count the positions are wrong. + ops: usize, + /// Where each inlined form's operators are: the placements in them are new. + ranges: Vec>, + /// Earlier placements refused only because their resources needed renaming + /// ([`expand::Refusal::renaming`]), each with the reason it was given. + waiting: Vec<(usize, String)>, +} + /// 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 { @@ -33,10 +44,17 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result = None; // One pass opens one level of every placement; the last pass only reports what is deeper. for pass in 0..=MAX_LEVELS { let p = page_dict(&next, page)?; @@ -47,11 +65,19 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result (Some(i.ranges), i.waiting), + _ => (None, Vec::new()), + }; + let mut still_waiting = Vec::new(); let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let space = expand::page_blend_space(&next, &p.dict); let images: std::collections::BTreeMap<_, _> = crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect(); - let mut replacements = std::collections::HashMap::new(); + let mut replacements = std::collections::BTreeMap::new(); let mut merged = resources; let mut pending_path = false; let mut in_text = false; @@ -68,6 +94,25 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result::to_vec); + // An earlier placement is read again only when it was refused for a rename that the + // names merged since have made unnecessary. The last pass opens nothing, so there it + // keeps its reason. + let earlier = new.as_ref().is_some_and(|ranges| !ranges.iter().any(|r| r.contains(&at))); + if earlier { + let Some(w) = waiting.iter().position(|(i, _)| *i == at) else { continue }; + let (_, reason) = waiting.swap_remove(w); + if pass == MAX_LEVELS || !expand::merges_without_renaming(&next, &merged, &name) { + still_waiting.push((at, reason)); + continue; + } + // What reading it again finds replaces the reason it was given. + if let Some(i) = result.refused.iter().position(|r| *r == reason) { + result.refused.remove(i); + } + } if pending_path || in_text { result.refused.push(format!("{}: the group is drawn inside an unfinished path or text object", image.name)); continue; @@ -78,14 +123,13 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result 64 { - return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + if !earlier { + visits = visits.saturating_add(1); + if visits > 64 { + return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + } } let translucent = expand::translucent_at(&next, &merged, &ops, at); - // The resource name exactly as the `Do` writes it (its only operand): 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 @@ -115,10 +159,14 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result { - result.refused.push(format!("{}: {}", image.name, refusal.error)); + let reason = format!("{}: {}", image.name, refusal.error); + if refusal.renaming { + still_waiting.push((at, reason.clone())); + } if refusal.intrinsic { refused_here.extend(image.object); } + result.refused.push(reason); } } } @@ -126,6 +174,14 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result (ops, false), None => (Vec::new(), true), diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs index 54495900cc..d674e1f473 100644 --- a/crates/edit/src/text/forms/expand.rs +++ b/crates/edit/src/text/forms/expand.rs @@ -67,6 +67,10 @@ pub(crate) struct Refusal { /// page so far is: its translucency or matrix, the resources it would merge into (which grow /// as other groups are prepared), or the decode budget left. pub(crate) intrinsic: bool, + /// The form's resources need renaming where it is drawn, and it draws something that would + /// look them up by their old names. Names merged in for other forms can make the renaming + /// unnecessary, which [`merges_without_renaming`] checks without reading the content. + pub(crate) renaming: bool, } /// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state @@ -83,7 +87,11 @@ pub(crate) fn ungroup_ops( placement: Matrix, budget: &mut DecodeBudget, ) -> Result { - let refusal = |why: &str, intrinsic: bool| Refusal { error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), intrinsic }; + let refusal = |why: &str, intrinsic: bool| Refusal { + error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), + intrinsic, + renaming: false, + }; // Something about this placement or the page so far is the reason. let refuse = |why: &str| refusal(why, false); // The form itself is the reason: every placement of it is refused the same way. @@ -215,7 +223,7 @@ pub(crate) fn ungroup_ops( // would find the page's, which differ wherever a name was renamed (every form resource that // isn't already the page's, the nested item's own entry included). if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { - return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources")); + return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") }); } let mut depth = 0usize; // Marked content and text objects must balance within the form's own stream: an extra EMC @@ -383,6 +391,22 @@ pub(crate) fn ungroup_ops( Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text }) } +/// Whether form `name` in `resources` would merge into them without a rename: it has resources +/// of its own, and each of their entries is already in `resources` under the same name, as +/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content. +pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool { + let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false }; + let Object::Stream(s) = &*doc.resolve(&form) else { return false }; + let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false }; + CATEGORIES.into_iter().all(|cat| { + let Some(theirs) = category(&own, cat) else { return true }; + let mine = category(resources, cat).unwrap_or_default(); + // Past these sizes `ungroup_ops` refuses the form anyway. + theirs.len() <= 4096 && mine.len() <= 4096 && theirs.iter().all(|(key, value)| mine.get(key) == Some(value)) + }) +} + /// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead /// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its /// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy. From 62b85edef1441d35b7bff49ee795b34191a1d8c2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:32:35 +0700 Subject: [PATCH 06/21] Write the Thai glyph check without a negated is_some_and Clippy's nonminimal_bool asks for is_none_or: a glyph with no outline range, or an empty one, is skipped as before. --- crates/fonts/src/pdf/thai.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 0f950afc6d..950cd9edb0 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -133,7 +133,7 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option start) { + if loca.get_raw(at).zip(loca.get_raw(at + 1)).is_none_or(|(start, end)| end <= start) { continue; } if advances.advance_width(skrifa::GlyphId::new(u32::from(gid))).is_none_or(|w| (w == 0.0) != is_thai_mark(ch)) { From 5850000e41308ea2f2b3b8ef9300eded8792fd37 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:38:57 +0700 Subject: [PATCH 07/21] Prepare grouped text with Ungroup's own form expansion text/forms/expand.rs was a drifted copy of ungroup.rs. It is gone: text preparation now opens each placement with crate::ungroup, so Ungroup and preparation share one implementation. What the copy added is now in ungroup.rs: a Refusal that says whether the form itself or only this placement is the reason (intrinsic) and whether a rename alone was the problem (renaming), the shows_text result, merges_without_renaming, refusals for unreadable /Resources and /Group entries and arrays with a non-number, a cap on resource counts and q nesting, a total size for pattern copies, and ending an unfinished path with the form. Ungroup's newer behaviour stays: names are read from the operand the renderer uses, so a surplus operand is renamed correctly rather than refused; default colour spaces are compared with the page's; and a group blending in DeviceRGB on a page that names no blending space is drawn inline, as Ungroup does, rather than refused as the copy did. What a form draws is now read within one decode allowance shared with the form itself, so a crafted group can no longer make one Ungroup decode 64 MiB for each of 1024 nested forms. A group whose content can't be checked is refused; for a form without a group, blend modes inside don't matter, and content that can't be checked only means preparation can't tell whether it has text (shows_text is None), which it reports. --- crates/edit/src/images.rs | 7 +- crates/edit/src/tests.rs | 101 ++-- crates/edit/src/text/forms.rs | 30 +- crates/edit/src/text/forms/expand.rs | 666 --------------------------- crates/edit/src/ungroup.rs | 222 ++++++--- 5 files changed, 249 insertions(+), 777 deletions(-) delete mode 100644 crates/edit/src/text/forms/expand.rs diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 9d5a333673..e8debc1e46 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1670,8 +1670,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op); let space = crate::ungroup::page_blend_space(doc, &p.dict); - let (inline, mut merged, patterns) = - crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?; + // One allowance for the form and everything it draws. + let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0); + let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } = + crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix), &mut budget) + .map_err(|r| r.error)?; if !patterns.is_empty() { let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); for (name, copy) in patterns { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4310dd30df..1abf5d4f92 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -11958,6 +11958,27 @@ fn ungroup_reads_each_nested_form_once_and_within_a_ceiling() { assert!(matches!(many, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{many:?}"); } +/// The forms a group draws are read within one allowance shared with the group itself, not 64 MiB +/// each: 70 forms that each inflate to 1 MiB are past it, so the group can't be checked and is +/// refused instead of being decoded 70 MiB deep (or, with 1024 of them, 64 GiB). +#[test] +fn ungroup_reads_what_a_group_draws_within_one_allowance() { + let r = ungroup_art(&(0..70).map(|i| format!("/F{i} Do ")).collect::().into_bytes(), true, |doc| { + let mut xo = Dict::new(); + for i in 0..70 { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + xo.set(format!("F{i}").into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(d, &vec![b' '; 1 << 20]))))); + } + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + res + }); + assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("can't be checked")), "{r:?}"); +} + /// A transparency group that draws a nested form whose content can't be read is refused: what /// it blends can't be known, so it isn't guessed. #[test] @@ -12722,39 +12743,32 @@ fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_i } } -/// 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. +/// A form whose content gives an operator a surplus operand (`0 /Inner Do`) is prepared as Ungroup +/// draws it: the renderer draws the last operand, and that is the one renamed with the inner +/// artwork's resource, so every drawn name still resolves. #[test] -fn grouped_text_with_surplus_resource_operands_is_left_grouped() { +fn grouped_text_with_a_surplus_resource_operand_renames_the_operand_drawn() { 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:?}"); + for body in ["BT /F1 14 Tf 40 500 Td (body) Tj ET 0 /Inner Do", "BT /F1 14 Tf 40 500 Td (body) Tj ET /Inner Do"] { + 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(body), + 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::>()); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 1, "{body}"); + 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:?}"); + } } } @@ -12795,14 +12809,14 @@ fn grouped_image_artwork_is_not_prepared() { assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); } -/// Artwork and groups refused for what they are (knockout groups, content with an operand too -/// many) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text +/// Artwork and groups refused for what they are (knockout groups, content that restores more +/// graphics states than it saves) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text /// group are 41 reads, within the 64-placement limit, where reading either again on the second /// pass would pass it and prepare nothing. #[test] fn artwork_and_refused_groups_count_once_against_the_placement_limit() { for knockout in [true, false] { - let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET 0 /Im Do" }; + let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET Q" }; let mut forms: Vec<(String, String)> = (1..=10).map(|i| (format!("Logo{i}"), "q 10 0 0 10 0 0 cm /Im Do Q".to_string())).collect(); forms.extend((1..=30).map(|i| (format!("Refused{i}"), refused_body.to_string()))); forms.push(("Body".into(), "BT /F1 14 Tf 40 300 Td (body) Tj ET".into())); @@ -12932,26 +12946,27 @@ fn grouped_text_under_a_non_utf8_resource_name_is_prepared() { 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. +/// Preparation follows Ungroup's rule for a transparency group that blends in DeviceRGB: it +/// composites as the page does on a page whose own group says DeviceRGB, or that names no +/// blending space (DeviceRGB on screen), and is prepared there; on a page that blends in CMYK it +/// is refused. #[test] fn grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb() { - for page_group in [false, true] { + for page_space in [None, Some("DeviceRGB"), Some("DeviceCMYK")] { 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 group = |cs: &str| build(&[&format!("<< /S /Transparency /CS /{cs} >>")]).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())); + s.dict.set(b"Group".to_vec(), Object::Dict(group("DeviceRGB"))); 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(); + if let Some(cs) = page_space { + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| d.set(b"Group".to_vec(), Object::Dict(group(cs)))).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 { + if page_space == Some("DeviceCMYK") { assert_eq!(result.expanded, 0); assert!(result.refused.iter().any(|r| r.contains("colour space of its own")), "{:?}", result.refused); + } else { + assert_eq!((result.expanded, result.refused.len()), (1, 0), "{page_space:?}: {:?}", result.refused); } } } diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index 0cdc33e84c..744b7e8df5 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -4,13 +4,11 @@ //! source glyph operators become page content; no cover rectangle or replacement text box is //! added. Unsupported compositing and malformed streams stay intact. -mod expand; - use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, Object}; use super::{ContentStream, Read, content_streams, joined, page_dict, rewritten_contents, splice}; -use crate::EditError; +use crate::{EditError, ungroup}; /// Result of preparing one page. Refused groups remain in the page, with an actionable reason. #[derive(Clone, Debug, Default)] @@ -31,7 +29,7 @@ struct Inlined { /// Where each inlined form's operators are: the placements in them are new. ranges: Vec>, /// Earlier placements refused only because their resources needed renaming - /// ([`expand::Refusal::renaming`]), each with the reason it was given. + /// ([`crate::ungroup::Refusal::renaming`]), each with the reason it was given. waiting: Vec<(usize, String)>, } @@ -74,7 +72,7 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result = crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect(); let mut replacements = std::collections::BTreeMap::new(); @@ -104,7 +102,7 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result { + let translucent = ungroup::translucent_at(&next, &merged, &ops, at); + match ungroup::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + Ok(ungroup::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 { - settled.extend(image.object); - continue; + match shows_text { + Some(true) => {} + Some(false) => { + settled.extend(image.object); + continue; + } + // Whether it has text can't be known: it is left as it is, and said. + None => { + result.refused.push(format!("{}: it draws recursive forms, or nested content that can't be checked", image.name)); + continue; + } } if pass == MAX_LEVELS { result.refused.push(format!("{}: its text is grouped more than {MAX_LEVELS} levels deep", image.name)); diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs deleted file mode 100644 index 229f70712e..0000000000 --- a/crates/edit/src/text/forms/expand.rs +++ /dev/null @@ -1,666 +0,0 @@ -//! Existing-text preparation: draw a Form XObject's content in place of its `Do` (#2964, #3005). -//! Adapted from mtlcl410's Ungroup implementation in PR #3067, revision -//! `6eafca9caa4a4d2174a395b53914bc9e689097cc` (MIT OR Apache-2.0). -//! -//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own -//! content streams. Ungrouping copies the form's content into the page at the same place, -//! wrapped as `q cm re W n … Q`, with the form's resources merged into a copy of -//! the page's under names that don't clash. Its text then edits like any other. The form object -//! itself is left alone, so another page or placement that draws it is unchanged. -//! -//! Drawing a form inline paints the same pixels unless the form is a transparency group whose -//! compositing differs from painting its parts one by one: a knockout group, a group that uses -//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft -//! mask, or a group with a blending colour space (`/CS`) 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 -//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose -//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused. - -use std::collections::HashMap; - -use pdfcraft_content::{Matrix, Op, parse}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; - -use crate::EditError; - -/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above -/// any real form, and a bomb must not be copied into the page. -const MAX_FORM_BYTES: usize = 16 << 20; - -/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). -const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; - -/// The most distinct patterns one ungrouped form may select; each becomes a new object. -const MAX_MOVED_PATTERNS: usize = 256; - -/// A name map for one resource category: form name → name in the page's resources. -type Renames = Vec<(Vec, Vec)>; - -/// What [`ungroup_ops`] gives back. -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, -} - -/// Why [`ungroup_ops`] left a placement grouped. -pub(crate) struct Refusal { - pub(crate) error: EditError, - /// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every - /// placement of it is refused the same way. Otherwise something about this placement or the - /// page so far is: its translucency or matrix, the resources it would merge into (which grow - /// as other groups are prepared), or the decode budget left. - pub(crate) intrinsic: bool, - /// The form's resources need renaming where it is drawn, and it draws something that would - /// look them up by their old names. Names merged in for other forms can make the renaming - /// unnecessary, which [`merges_without_renaming`] checks without reading the content. - pub(crate) renaming: 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 -/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). -/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, -/// then the CTM there). -pub(crate) fn ungroup_ops( - doc: &Document, - resources: &Dict, - name: &[u8], - translucent: bool, - page_space: Option<&Object>, - placement: Matrix, - budget: &mut DecodeBudget, -) -> Result { - let refusal = |why: &str, intrinsic: bool| Refusal { - error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), - intrinsic, - renaming: false, - }; - // Something about this placement or the page so far is the reason. - let refuse = |why: &str| refusal(why, false); - // The form itself is the reason: every placement of it is refused the same way. - let invalid = |why: &str| refusal(why, true); - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; - let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; - let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; - if !is_name(doc, &s.dict, b"Subtype", b"Form") { - return Err(refuse("it is not a Form XObject")); - } - let numbers = |key: &[u8], n: usize| -> Option> { - let a = s.dict.get(key).map(|o| doc.resolve(o))?; - if a.as_array()?.len() != n { - return None; - } - let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) - }; - let bbox = numbers(b"BBox", 4).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?; - let matrix = match s.dict.get(b"Matrix") { - None => None, - Some(_) => Some(Matrix( - <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| invalid("its matrix is unreadable"))?) - .map_err(|_| invalid("its matrix is unreadable"))?, - )), - }; - let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); - if s.dict.get(b"Resources").is_some() && own.is_none() { - return Err(invalid("its resources cannot be read")); - } - // A form without its own resources uses the ones it is drawn with (PDF 1.1). - let form_res = own.clone().unwrap_or_else(|| resources.clone()); - - // A tagged form's content is a structure element's: drawn inline it would lose that link. - if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() { - return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags")); - } - let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency")); - if s.dict.get(b"Group").is_some() && group.is_none() { - return Err(invalid("its group dictionary cannot be read")); - } - if let Some(group) = &group { - if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { - return Err(invalid("it is a knockout transparency group")); - } - if translucent { - return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); - } - // Its contents composite in its own colour space (ISO 32000-2 §11.6.6, Table 145 /CS); - // inline they would composite in the page's. - if let Some(cs) = group.get(b"CS") - && !same_blend_space(doc, cs, page_space) - { - return Err(invalid("it is a transparency group that blends in a colour space of its own")); - } - } - - // Strictly: damaged data must not be copied in as a shorter form. - let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; - let parsed = parse(&data); - if parsed.skipped > 0 { - return Err(invalid("its content has parts that can't be read")); - } - let mut ops = parsed.ops; - // A name is renamed at a fixed 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. Checked before the nested - // walk, which can only call it unverifiable. - if ops.iter().any(|op| takes_operands(&op.op).is_some_and(|n| op.operands.len() > n)) { - return Err(invalid("its content gives an operator more operands than it takes")); - } - 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, 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(); - let mut renames: Vec<(&[u8], Renames)> = Vec::new(); - if own.is_some() { - for cat in CATEGORIES { - let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; - if theirs.len() > 4096 { - return Err(invalid("too many resources")); - } - let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - if mine.len() > 4096 { - return Err(refuse("too many resources")); - } - let mut map = Renames::new(); - let mut n = 0u32; - for (key, value) in theirs.iter() { - if cat == b"ColorSpace" && matches!(key.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") && mine.get(key) != Some(value) { - return Err(refuse("it overrides a default colour space")); - } - if mine.get(key) == Some(value) { - continue; - } - let fresh = loop { - n = n.saturating_add(1); - let candidate = format!("PCUg{n}").into_bytes(); - if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() { - break candidate; - } - if n == u32::MAX { - return Err(refuse("too many resources")); - } - }; - mine.set(fresh.clone(), value.clone()); - map.push((key.to_vec(), fresh)); - } - merged.set(cat.to_vec(), Object::Dict(mine)); - if !map.is_empty() { - renames.push((cat, map)); - } - } - } - let renamed = |cat: &[u8], name: &[u8]| -> Option> { - renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone()) - }; - // A nested form, Type 3 font or tiling pattern without resources of its own looks its names - // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it - // would find the page's, which differ wherever a name was renamed (every form resource that - // isn't already the page's, the nested item's own entry included). - if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { - return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") }); - } - let mut depth = 0usize; - // Marked content and text objects must balance within the form's own stream: an extra EMC - // would close one the page opened, and one left open would take in the page's later content. - let mut marked = 0usize; - let mut in_text = false; - for op in &mut ops { - match op.op.as_slice() { - b"BDC" | b"BMC" => marked = marked.saturating_add(1), - b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?, - b"BT" if in_text => return Err(invalid("its content begins a text object inside another")), - b"BT" => in_text = true, - b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")), - b"ET" => in_text = false, - _ => {} - } - let slot: Option<(&[u8], usize)> = match op.op.as_slice() { - b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => { - let cat: &[u8] = match op.op.as_slice() { - b"Tf" => b"Font", - b"Do" => b"XObject", - b"gs" => b"ExtGState", - b"sh" => b"Shading", - _ => b"ColorSpace", - }; - Some((cat, 0)) - } - b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)), - b"BDC" | b"DP" => Some((&b"Properties"[..], 1)), - b"q" => { - if depth >= 4096 { - return Err(invalid("graphics state is too deeply nested")); - } - depth += 1; - None - } - b"Q" => { - depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?; - None - } - _ => None, - }; - if let Some((cat, i)) = slot - && let Some(operand) = op.operands.get_mut(i) - && let Object::Name(n) = operand - && let Some(to) = renamed(cat, n) - { - *operand = Object::Name(to); - } - if let Some((dict, _)) = &mut op.inline { - for key in [&b"CS"[..], b"ColorSpace"] { - if let Some(Object::Name(n)) = dict.get(key) - && let Some(to) = renamed(b"ColorSpace", n) - { - dict.set(key.to_vec(), Object::Name(to)); - } - } - } - } - - if marked > 0 || in_text { - return Err(invalid("its content leaves marked content or a text object open")); - } - - // Patterns keep their place relative to the shapes they fill: each one the form selects is - // drawn from a copy that also carries the placement. Nested forms and patterns inside - // patterns keep their own parents, so only this stream's selections change. - let mut patterns: Vec<(Vec, Object)> = Vec::new(); - let mut pattern_bytes = MAX_FORM_BYTES; - if placement != Matrix::IDENTITY { - let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let mut moved: Renames = Vec::new(); - let mut n = 0u32; - for op in &mut ops { - if !(op.is("scn") || op.is("SCN")) { - continue; - } - let Some(i) = op.operands.len().checked_sub(1) else { continue }; - let Some(Object::Name(from)) = op.operands.get(i) else { continue }; - // A name the resources don't have selects nothing, inline or not. - let Some(entry) = table.get(from).cloned() else { continue }; - let to = match moved.iter().find(|(f, _)| f == from) { - Some((_, to)) => to.clone(), - None => { - if moved.len() >= MAX_MOVED_PATTERNS { - return Err(refuse("it paints with too many patterns")); - } - let copy = - moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?; - if let Object::Stream(s) = © { - pattern_bytes = - pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies exceed the editing budget"))?; - } - let fresh = loop { - n = n.saturating_add(1); - let candidate = format!("PCUgPat{n}").into_bytes(); - if table.get(&candidate).is_none() { - break candidate; - } - if n == u32::MAX { - return Err(refuse("too many resources")); - } - }; - // Reserved here; the caller replaces it with the copy's reference. - table.set(fresh.clone(), Object::Null); - patterns.push((fresh.clone(), copy)); - moved.push((from.clone(), fresh.clone())); - fresh - } - }; - if let Some(operand) = op.operands.get_mut(i) { - *operand = Object::Name(to); - } - } - if !patterns.is_empty() { - merged.set(b"Pattern".to_vec(), Object::Dict(table)); - } - } - - // A form in a layer (/OC) stays in it: its content is marked with the same membership. - let layer = match s.dict.get(b"OC") { - None => None, - Some(oc) => { - let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone()); - let name = match existing { - Some(k) => k, - None => { - let fresh = (1..=u32::MAX) - .map(|n| format!("PCUgOC{n}").into_bytes()) - .find(|c| props.get(c).is_none()) - .ok_or_else(|| refuse("too many resources"))?; - props.set(fresh.clone(), oc.clone()); - merged.set(b"Properties".to_vec(), Object::Dict(props)); - fresh - } - }; - Some(name) - } - }; - - let num = |v: f64| Object::Real(v); - let mut out = Vec::new(); - if let Some(name) = &layer { - out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())])); - } - out.push(Op::new("q", Vec::new())); - if let Some(m) = matrix { - out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); - } - let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| invalid("invalid form bounds"))?; - let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); - out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); - out.push(Op::new("W", Vec::new())); - out.push(Op::new("n", Vec::new())); - out.extend(ops); - // A form discards its unfinished current path when returning to its caller. - out.push(Op::new("n", Vec::new())); - // A form ends with the state it was drawn with (the Do saves and restores it). - out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); - out.push(Op::new("Q", Vec::new())); - if layer.is_some() { - out.push(Op::new("EMC", Vec::new())); - } - Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text }) -} - -/// Whether form `name` in `resources` would merge into them without a rename: it has resources -/// of its own, and each of their entries is already in `resources` under the same name, as -/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content. -pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool { - let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); - let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false }; - let Object::Stream(s) = &*doc.resolve(&form) else { return false }; - let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false }; - CATEGORIES.into_iter().all(|cat| { - let Some(theirs) = category(&own, cat) else { return true }; - let mine = category(resources, cat).unwrap_or_default(); - // Past these sizes `ungroup_ops` refuses the form anyway. - theirs.len() <= 4096 && mine.len() <= 4096 && theirs.iter().all(|(key, value)| mine.get(key) == Some(value)) - }) -} - -/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead -/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its -/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy. -fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option { - let resolved = doc.resolve(entry); - let (mut dict, stream) = match &*resolved { - Object::Stream(s) => (s.dict.clone(), Some(s)), - Object::Dict(d) => (d.clone(), None), - _ => return None, - }; - let own = match dict.get(b"Matrix") { - None => Matrix::IDENTITY, - Some(m) => { - let m = doc.resolve(m); - if m.as_array()?.len() != 6 { - return None; - } - let nums: Vec = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect(); - Matrix::from_operands(&nums)? - } - }; - let moved = own.then(&placement); - if !moved.0.iter().all(|v| v.is_finite()) { - return None; - } - dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect())); - match stream { - Some(s) if s.raw.len() > MAX_FORM_BYTES => None, - Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })), - None => Some(Object::Dict(dict)), - } -} - -/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending. -const MAX_NESTING: usize = 8; - -/// The most nested forms and tiling patterns [`blends_inside`] reads in all; past that the rest -/// count as blending, so a file can't make the check run for ever. -const MAX_NESTED_READS: usize = 64; - -/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a -/// form drawing itself), the 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, - 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 -/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. -fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { - let (cat, i): (&[u8], usize) = match op.op.as_slice() { - b"Do" => (b"XObject", 0), - b"Tf" => (b"Font", 0), - b"scn" | b"SCN" => match op.operands.len().checked_sub(1) { - Some(i) => (b"Pattern", i), - None => return false, - }, - _ => return false, - }; - let Some(Object::Name(name)) = op.operands.get(i) else { return false }; - let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { - return false; - }; - let dict = match &*doc.resolve(&entry) { - Object::Stream(s) => s.dict.clone(), - Object::Dict(d) => d.clone(), - _ => return false, - }; - let drawn = match cat { - b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"), - b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"), - // A shading pattern has no content of its own. - _ => int_of(doc, &dict, b"PatternType") == Some(1), - }; - drawn && dict.get(b"Resources").is_none() -} - -/// Whether `ops` blend through a used graphics state, nested form or pattern. Unreadable, -/// recursive or over-budget nested content is treated as blending, so preparation refuses it. -/// 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; - } - let o = if op.is("Do") { - op.name(0).and_then(|n| xobjects.get(n)) - } else if op.is("scn") || op.is("SCN") { - op.operands.len().checked_sub(1).and_then(|i| op.name(i)).and_then(|n| patterns.get(n)) - } else { - continue; - }; - let Some(o) = o else { continue }; - let resolved = doc.resolve(o); - let nested = match &*resolved { - Object::Stream(st) if op.is("Do") && is_name(doc, &st.dict, b"Subtype", b"Form") => st, - Object::Stream(st) if !op.is("Do") && int_of(doc, &st.dict, b"PatternType") == Some(1) => st, - // A shading pattern. - Object::Stream(st) if !op.is("Do") => { - if state_blends(st.dict.get(b"ExtGState")) { - return true; - } - continue; - } - Object::Dict(d) if !op.is("Do") => { - if state_blends(d.get(b"ExtGState")) { - return true; - } - continue; - } - _ => continue, - }; - let r = o.as_ref(); - 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 { - return true; - } - walk.reads += 1; - let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true }; - let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); - let has_own = own_resources.is_some(); - let own = own_resources.unwrap_or_else(|| resources.clone()); - let inner = parse(&data); - if inner.skipped > 0 { - return true; - } - if let Some(r) = r { - walk.path.push(r); - } - let 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, inner_text)); - } - } - if blends { - return true; - } - } - false -} - -/// The blending colour space of the page group (ISO 32000-2 §11.4.7): the page's transparency -/// `/Group /CS`, resolved. `None` when the page names none, and the PDF processor chooses. -pub(crate) fn page_blend_space(doc: &Document, page: &Dict) -> Option { - let group = page.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"))?; - group.get(b"CS").map(|cs| (*doc.resolve(cs)).clone()) -} - -/// Whether a group blending in `cs` composites as the page does: 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 { - 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 -/// be one, ISO 32000-2 §7.3.10). -fn is_name(doc: &Document, d: &Dict, key: &[u8], want: &[u8]) -> bool { - d.get(key).is_some_and(|v| doc.resolve(v).as_name() == Some(want)) -} - -/// `d`'s integer `key`, directly or through an indirect object. -fn int_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { - d.get(key).and_then(|v| doc.resolve(v).as_int()) -} - -/// Whether an ExtGState blends with something other than Normal. -fn blends(doc: &Document, gs: &Dict) -> bool { - let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false }; - let first = match &*bm { - Object::Array(a) => a.first().map(|x| doc.resolve(x)), - _ => Some(bm.clone()), - }; - !first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible"))) -} - -/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's -/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs` -/// changes only the parameters it sets. -pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool { - let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - // Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal. - let mut stack: Vec<[bool; 4]> = Vec::new(); - let mut now = [false; 4]; - for op in ops.iter().take(at) { - match op.op.as_slice() { - b"q" => stack.push(now), - b"Q" => now = stack.pop().unwrap_or([false; 4]), - b"gs" => { - let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else { - continue; - }; - let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0); - if let Some(v) = alpha(b"ca") { - now[0] = v; - } - if let Some(v) = alpha(b"CA") { - now[1] = v; - } - if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) { - now[2] = m.as_name() != Some(b"None"); - } - if gs.get(b"BM").is_some() { - now[3] = blends(doc, &gs); - } - } - _ => {} - } - } - now.iter().any(|v| *v) -} diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 76a1f3fe5c..d2c9842717 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -23,17 +23,21 @@ //! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is //! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose //! `/Matrix` also carries the form's placement; one that can't be copied that way is refused. +//! +//! Edit text's preparation of grouped text (`text::forms`) opens each placement with this same +//! code, and decides what to open from what [`Refusal`] and [`Ungrouped::shows_text`] say. use std::collections::HashMap; use pdfcraft_content::{Matrix, Op, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::EditError; /// The largest form content ungrouped (decoded bytes): the page's own content cap is far above -/// any real form, and a bomb must not be copied into the page. -const MAX_FORM_BYTES: usize = 64 << 20; +/// any real form, and a bomb must not be copied into the page. Ungroup reads the form and what it +/// draws within one [`DecodeBudget`] of this size. +pub(crate) const MAX_FORM_BYTES: usize = 64 << 20; /// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; @@ -45,19 +49,50 @@ const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK /// The most distinct patterns one ungrouped form may select; each becomes a new object. const MAX_MOVED_PATTERNS: usize = 256; +/// The most entries of one resource category merged: a form or page with more is refused. +const MAX_RESOURCES: usize = 4096; + +/// The deepest `q` nesting a form's content may have. +const MAX_SAVES: usize = 4096; + /// 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. `None` when something it draws can't be read, + /// is nested too deep, draws itself, or is past the reads or the decode budget allowed. + pub(crate) shows_text: Option, +} + +/// Why [`ungroup_ops`] left a placement grouped. +pub(crate) struct Refusal { + pub(crate) error: EditError, + /// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every + /// placement of it is refused the same way. Otherwise something about this placement or the + /// page so far is: its translucency or matrix, the resources it would merge into (which grow + /// as other groups are prepared), or the decode budget left. + pub(crate) intrinsic: bool, + /// The form's resources need renaming where it is drawn, and it draws something that would + /// look them up by their old names. Names merged in for other forms can make the renaming + /// unnecessary, which [`merges_without_renaming`] checks without reading the content. + pub(crate) renaming: 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 /// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). /// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, -/// then the CTM there). +/// then the CTM there). The form, and the forms and patterns it draws, decode within `budget`. pub(crate) fn ungroup_ops( doc: &Document, resources: &Dict, @@ -65,8 +100,17 @@ pub(crate) fn ungroup_ops( translucent: bool, page_space: Option<&Object>, placement: Matrix, -) -> Result { - let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); + budget: &mut DecodeBudget, +) -> Result { + let refusal = |why: &str, intrinsic: bool| Refusal { + error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), + intrinsic, + renaming: false, + }; + // Something about this placement or the page so far is the reason. + let refuse = |why: &str| refusal(why, false); + // The form itself is the reason: every placement of it is refused the same way. + let invalid = |why: &str| refusal(why, true); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; @@ -75,29 +119,35 @@ pub(crate) fn ungroup_ops( } let numbers = |key: &[u8], n: usize| -> Option> { let a = s.dict.get(key).map(|o| doc.resolve(o))?; - let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) + let a = a.as_array()?; + // Every entry must be a number: a short array padded out by a non-number is unreadable. + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (a.len() == n && v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v) }; - let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?; + let bbox = numbers(b"BBox", 4).and_then(|b| <[f64; 4]>::try_from(b).ok()).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?; let matrix = match s.dict.get(b"Matrix") { None => None, - Some(_) => Some(Matrix( - <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?) - .map_err(|_| refuse("its matrix is unreadable"))?, - )), + Some(_) => Some(Matrix(numbers(b"Matrix", 6).and_then(|m| <[f64; 6]>::try_from(m).ok()).ok_or_else(|| invalid("its matrix is unreadable"))?)), }; let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + if s.dict.get(b"Resources").is_some() && own.is_none() { + return Err(invalid("its resources can't be read")); + } // A form without its own resources uses the ones it is drawn with (PDF 1.1). let form_res = own.clone().unwrap_or_else(|| resources.clone()); // A tagged form's content is a structure element's: drawn inline it would lose that link. if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() { - return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags")); + return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags")); } - let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency")); + let group_dict = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()); + if s.dict.get(b"Group").is_some() && group_dict.is_none() { + return Err(invalid("its group dictionary can't be read")); + } + let group = group_dict.filter(|g| is_name(doc, g, b"S", b"Transparency")); if let Some(group) = &group { if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { - return Err(refuse("it is a knockout transparency group")); + return Err(invalid("it is a knockout transparency group")); } if translucent { return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); @@ -107,24 +157,27 @@ pub(crate) fn ungroup_ops( if let Some(cs) = group.get(b"CS") && !same_blend_space(doc, cs, page_space) { - return Err(refuse("it is a transparency group that blends in a colour space of its own")); + return Err(invalid("it is a transparency group that blends in a colour space of its own")); } } // Strictly: damaged data must not be copied in as a shorter form. - let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; + let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?; let parsed = parse(&data); if parsed.skipped > 0 { - return Err(refuse("its content has parts that can't be read")); + return Err(invalid("its content has parts that can't be read")); } let mut ops = parsed.ops; - if group.is_some() { - // 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 }; - if blends_inside(doc, &form_res, &ops, 0, &mut walk) { - return Err(refuse("it is a transparency group with blend modes inside")); - } + // What it draws is read for text whether it is a group or not, and for blend modes only in a + // group: without one, blending inside draws the same inline. The form itself is being read, + // so drawing itself again is content that can't be checked. + let mut walk = + BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false, group: group.is_some() }; + let unchecked = blends_inside(doc, &form_res, &ops, 0, &mut walk); + if unchecked && group.is_some() { + return Err(refuse("it is a transparency group with blend modes inside, or content inside that can't be checked")); } + let shows_text = (!unchecked).then_some(walk.text); // A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline, // the page's resources are the current ones (§7.8.3). @@ -141,7 +194,13 @@ pub(crate) fn ungroup_ops( if own.is_some() { for cat in CATEGORIES { let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; + if theirs.len() > MAX_RESOURCES { + return Err(invalid("too many resources")); + } let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + if mine.len() > MAX_RESOURCES { + return Err(refuse("too many resources")); + } let mut map = Renames::new(); let mut n = 0u32; for (key, value) in theirs.iter() { @@ -177,7 +236,7 @@ pub(crate) fn ungroup_ops( // would find the page's, which differ wherever a name was renamed (every form resource that // isn't already the page's, the nested item's own entry included). if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { - return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources")); + return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") }); } let mut depth = 0usize; // Marked content and text objects must balance within the form's own stream: an extra EMC @@ -187,19 +246,20 @@ pub(crate) fn ungroup_ops( for op in &mut ops { match op.op.as_slice() { b"BDC" | b"BMC" => marked = marked.saturating_add(1), - b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?, - b"BT" if in_text => return Err(refuse("its content begins a text object inside another")), + b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?, + b"BT" if in_text => return Err(invalid("its content begins a text object inside another")), b"BT" => in_text = true, - b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")), + b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")), b"ET" => in_text = false, _ => {} } match op.op.as_slice() { - b"q" => depth = depth.saturating_add(1), - b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?, + b"q" if depth >= MAX_SAVES => return Err(invalid("its graphics states are nested too deeply")), + b"q" => depth += 1, + b"Q" => depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?, _ => {} } - let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?; + let slot = resource_operand(op).map_err(|Unreadable| invalid("its content selects a resource with operands that can't be read"))?; if let Some((cat, i)) = slot && let Some(operand) = op.operands.get_mut(i) && let Object::Name(n) = operand @@ -219,13 +279,15 @@ pub(crate) fn ungroup_ops( } if marked > 0 || in_text { - return Err(refuse("its content leaves marked content or a text object open")); + return Err(invalid("its content leaves marked content or a text object open")); } // Patterns keep their place relative to the shapes they fill: each one the form selects is // drawn from a copy that also carries the placement. Nested forms and patterns inside // patterns keep their own parents, so only this stream's selections change. let mut patterns: Vec<(Vec, Object)> = Vec::new(); + // What the copies of tiling patterns may add in all. + let mut pattern_bytes = MAX_FORM_BYTES; if placement != Matrix::IDENTITY { let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let mut moved: Renames = Vec::new(); @@ -243,6 +305,9 @@ pub(crate) fn ungroup_ops( } let copy = moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?; + if let Object::Stream(s) = © { + pattern_bytes = pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies would be too large"))?; + } let fresh = loop { n = n.saturating_add(1); let candidate = format!("PCUgPat{n}").into_bytes(); @@ -300,18 +365,40 @@ pub(crate) fn ungroup_ops( if let Some(m) = matrix { out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); } - let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3])); + let [a, b, c, d] = bbox; + let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); out.push(Op::new("W", Vec::new())); out.push(Op::new("n", Vec::new())); out.extend(ops); + // A path the form leaves unfinished ends with it, not in the page's content after it. + out.push(Op::new("n", Vec::new())); // A form ends with the state it was drawn with (the Do saves and restores it). out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); out.push(Op::new("Q", Vec::new())); if layer.is_some() { out.push(Op::new("EMC", Vec::new())); } - Ok((out, merged, patterns)) + Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text }) +} + +/// Whether form `name` in `resources` would merge into them without a rename: it has resources +/// of its own, and each of their entries is already in `resources` under the same name, as +/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content. +pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool { + let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false }; + let Object::Stream(s) = &*doc.resolve(&form) else { return false }; + let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false }; + CATEGORIES.into_iter().all(|cat| { + let Some(theirs) = category(&own, cat) else { return true }; + let mine = category(resources, cat).unwrap_or_default(); + // Past these sizes `ungroup_ops` refuses the form anyway. Default colour spaces are + // never renamed (`ungroup_ops` checks them separately). + theirs.len() <= MAX_RESOURCES + && mine.len() <= MAX_RESOURCES + && theirs.iter().all(|(key, value)| mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..]))) + }) } /// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace` @@ -339,6 +426,10 @@ fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option { let m = doc.resolve(m); let nums: Vec = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect(); + // A matrix is six numbers: a longer array is unreadable, not cut short. + if nums.len() != 6 { + return None; + } Matrix::from_operands(&nums)? } }; @@ -362,12 +453,23 @@ const MAX_NESTING: usize = 8; const MAX_NESTED_READS: usize = 1024; /// What [`blends_inside`] carries through its nested reads: the forms on the current path (a -/// form drawing itself), the answer for each form with resources of its own (its answer doesn't -/// depend on who draws it), and how many reads it has made. -struct BlendWalk { +/// form drawing itself), the answers for each form with resources of its own (whether it blends +/// or can't be checked, and whether it shows text; neither depends on who draws it), how many +/// reads it has made, the decode budget they share with the form itself, whether the content +/// being walked shows text itself or through a Form XObject it draws, and whether blend modes +/// count (they do in a group). +struct BlendWalk<'a> { path: Vec, - known: HashMap, + known: HashMap, reads: usize, + budget: &'a mut DecodeBudget, + text: bool, + group: 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"\"") } /// An operator whose operands the renderer can't read as it expects, so it skips the operator. @@ -440,15 +542,22 @@ fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { drawn && dict.get(b"Resources").is_none() } -/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they -/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through -/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't -/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather -/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped. -fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool { +/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts +/// only in a group (`walk.group`): a `gs` they use, or one used by a form or pattern they draw, at +/// any depth. A shading pattern blends through its own `/ExtGState`, a tiling pattern through its +/// content. A nested form or pattern that can't be read, is nested too deep, draws itself or is +/// past the reads or the budget allowed counts as blending, so the caller refuses rather than +/// guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped. 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)); + let group = walk.group; + let state_blends = |g: Option<&Object>| group && 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 { let Ok(Some((cat, i))) = resource_operand(op) else { continue }; let Some(name) = op.name(i) else { continue }; @@ -479,17 +588,19 @@ 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 { return true; } walk.reads += 1; - let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true }; + let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true }; let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); let has_own = own_resources.is_some(); let own = own_resources.unwrap_or_else(|| resources.clone()); @@ -500,11 +611,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 { From 7169b0d6ad760b3fe5d6f07b964f0970794dfef6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:41:20 +0700 Subject: [PATCH 08/21] Ungroup refuses a form the page's default colour space would remap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A form with resources of its own and no /DefaultRGB (likewise Gray, CMYK) draws its device colours as they are. Inline, the page's resources apply, and a /DefaultRGB there would remap them (ISO 32000-2 §8.6.5.6). Only the form having an entry the page lacks was refused; now either side having one the other doesn't share is. --- crates/edit/src/tests.rs | 7 ++++++- crates/edit/src/ungroup.rs | 40 +++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 1abf5d4f92..934153a321 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5144,7 +5144,9 @@ fn spaces(entries: &[(&str, Object)]) -> Dict { /// A form's `/DefaultRGB` (likewise `/DefaultGray`, `/DefaultCMYK`) remaps the device colours it /// paints (ISO 32000-2 §8.6.5.6), looked up in the current resource dictionary (§7.8.3). Inline, /// that is the page's: renamed into it, the form's entry would no longer remap anything and its -/// red would draw in another colour, so Ungroup refuses unless the page has the same entry. +/// red would draw in another colour, so Ungroup refuses unless the page has the same entry. A +/// page's entry that the form's own resources lack would remap the form's colours inline, so that +/// is refused too. #[test] fn ungroup_refuses_a_form_with_its_own_default_colour_space() { let refused = |r: Result<(), EditError>, key: &str| match r { @@ -5159,6 +5161,9 @@ fn ungroup_refuses_a_form_with_its_own_default_colour_space() { // Only an entry of the same name counts: the page's DefaultRGB doesn't stand for a DefaultCMYK. let r = ungroup_with_spaces(|_| (spaces(&[("DefaultCMYK", dark_cal_rgb())]), Some(spaces(&[("DefaultRGB", dark_cal_rgb())])))).0; refused(r, "/DefaultCMYK"); + // The other way round: the page's DefaultRGB would remap the red of a form whose own + // resources have none, which draws it as DeviceRGB red. + refused(ungroup_with_spaces(|_| (spaces(&[]), Some(spaces(&[("DefaultRGB", dark_cal_rgb())])))).0, "/DefaultRGB"); } /// When the page's resources have the same default colour space (the same object, or an equal diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index d2c9842717..73c255f747 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -13,11 +13,12 @@ //! 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 a form whose -//! `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` the page doesn't share (it remaps the form's -//! device colours, §8.6.5.6, and inline the page's resources apply), 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`. +//! composite in the page's. Those are refused rather than changed, as is a form with resources +//! of its own whose `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` differs from the page's, one +//! having it and the other not included (it remaps the device colours painted with it, +//! §8.6.5.6, and inline the page's resources apply), 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 @@ -182,10 +183,14 @@ pub(crate) fn ungroup_ops( // A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline, // the page's resources are the current ones (§7.8.3). if own.is_some() - && let Some(key) = unshared_default_space(doc, &form_res, resources) + && let Some((key, form_has)) = unshared_default_space(doc, &form_res, resources) { let key = String::from_utf8_lossy(key); - return Err(refuse(&format!("it remaps its device colours with a /{key} the page doesn't share"))); + return Err(refuse(&if form_has { + format!("it remaps its device colours with a /{key} the page doesn't share") + } else { + format!("the page's /{key} would remap its device colours, which it draws as they are") + })); } // Merge the form's resources into a copy of the page's, renaming what clashes. @@ -401,14 +406,23 @@ pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[ }) } -/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace` -/// resources that the page's don't have with an equal value (the same object, or the same value -/// resolved). A default colour space remaps the device colours painted with the resource -/// dictionary it is in (ISO 32000-2 §8.6.5.6, §7.8.3), so inline only the page's would apply. -fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<&'static [u8]> { +/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` that the form's and the page's +/// `/ColorSpace` resources don't share with an equal value (the same object, or the same value +/// resolved), and whether the form has it (else only the page does). A default colour space +/// remaps the device colours painted with the resource dictionary it is in (ISO 32000-2 +/// §8.6.5.6, §7.8.3), so inline only the page's would apply: the form's would no longer remap +/// its colours, and the page's would start to. +fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<(&'static [u8], bool)> { let spaces = |res: &Dict| res.get(b"ColorSpace").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let (theirs, mine) = (spaces(form), spaces(page)); - DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some_and(|v| !mine.get(key).is_some_and(|p| *doc.resolve(p) == *doc.resolve(v)))) + let differs = |key: &[u8]| match (theirs.get(key), mine.get(key)) { + (None, None) => false, + (Some(v), Some(p)) => *doc.resolve(p) != *doc.resolve(v), + _ => true, + }; + // The form's own entries are named first. + let form_has = DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some() && differs(key)).map(|key| (key, true)); + form_has.or_else(|| DEFAULT_SPACES.into_iter().find(|key| differs(key)).map(|key| (key, false))) } /// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead From 233bc6c9328e821eb0a267f23e178c8b55f35fc6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:42:10 +0700 Subject: [PATCH 09/21] Ungroup keeps the translucency an unmatched Q doesn't restore A Q with no q before it restores nothing; the renderer ignores it. The check for translucency at the Do took it as restoring the opaque default, so a transparency group drawn after /H gs Q was inlined although it is drawn translucent. --- crates/edit/src/tests.rs | 2 ++ crates/edit/src/ungroup.rs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 934153a321..a10ace0ae1 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5041,6 +5041,8 @@ fn ungroup_refuses_what_would_draw_differently() { ungroup(&[("Group", group(&[]))], fill, "/H gs /One gs", half()).unwrap(); // The translucency ends with its q … Q scope. ungroup(&[("Group", group(&[]))], fill, "q /H gs Q", half()).unwrap(); + // A Q with no q to restore is ignored, as the renderer ignores it: the translucency stays. + refused(ungroup(&[("Group", group(&[]))], fill, "/H gs Q", half()), "translucent"); // Without a group, translucency at the Do applies to each part just as it would inline. ungroup(&[], fill, "/H gs", half()).unwrap(); ungroup(&[("Group", group(&[]))], fill, "", None).unwrap(); diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 73c255f747..ed7ba908a7 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -691,7 +691,8 @@ pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: u for op in ops.iter().take(at) { match op.op.as_slice() { b"q" => stack.push(now), - b"Q" => now = stack.pop().unwrap_or([false; 4]), + // A Q with nothing to restore is ignored, as renderers ignore it. + b"Q" => now = stack.pop().unwrap_or(now), b"gs" => { let Ok(Some((_, i))) = resource_operand(op) else { continue }; let Some(gs) = op.name(i).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else { From 90b88ddcb658fc383a99640949454f3e5331795e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:44:55 +0700 Subject: [PATCH 10/21] Ungroup reads the Do it replaces and refuses one inside text or a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ungroup and text preparation now check the placement in one place, ungroup_ops, which is given the page's operators and the Do's index: - The form is looked up by the name the Do writes, byte for byte. Ungroup looked it up by the picture's display name, a lossy UTF-8 copy, so a form named /F#FF was 'missing'. - A Do with more than one operand is refused: the picture was read for its first, the renderer draws its last. - A Do inside BT…ET or an unfinished path is refused. Inline, the form's text objects would be nested in the page's, and its clip would take in the page's path. Preparation already refused these; Ungroup didn't. --- crates/edit/src/images.rs | 4 +--- crates/edit/src/tests.rs | 24 +++++++++++++++++++ crates/edit/src/text/forms.rs | 29 ++++------------------- crates/edit/src/ungroup.rs | 43 +++++++++++++++++++++++++---------- 4 files changed, 60 insertions(+), 40 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e8debc1e46..8ac77c9deb 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1668,13 +1668,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into())); } let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op); let space = crate::ungroup::page_blend_space(doc, &p.dict); // One allowance for the form and everything it draws. let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0); let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } = - crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix), &mut budget) - .map_err(|r| r.error)?; + crate::ungroup::ungroup_ops(doc, &res, &ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget).map_err(|r| r.error)?; if !patterns.is_empty() { let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); for (name, copy) in patterns { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a10ace0ae1..a309d962ab 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12944,6 +12944,30 @@ fn a_group_refused_for_renaming_is_prepared_once_the_page_has_its_names() { assert!(["alpha", "beta"].iter().all(|t| blocks.iter().any(|b| b.text == *t)), "{blocks:?}"); } +/// Ungroup finds the form by the name its `Do` writes, byte for byte: `/F#FF` is not UTF-8, and +/// the picture's display name (`F�`) names no resource. +#[test] +fn ungroup_finds_a_form_under_a_non_utf8_name() { + let mut doc = page_with_forms("/F#FF Do", &[("F#FF".into(), "BT /F1 14 Tf 40 500 Td (named) Tj ET".into())]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "named")); +} + +/// Ungroup refuses a form drawn inside a text object or an unfinished path: inline, its text +/// objects would be nested in the page's, and its clip would take in the page's path (which its +/// `n` then discards). The same form drawn outside them is ungrouped. +#[test] +fn ungroup_refuses_a_form_drawn_inside_text_or_a_path() { + let form = [("Body".to_string(), "BT /F1 14 Tf 40 500 Td (body) Tj ET".to_string())]; + for content in ["BT /F1 14 Tf /Body Do ET", "10 10 m 20 20 l /Body Do S"] { + let mut doc = page_with_forms(content, &form); + let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("unfinished path or text object")), "{content}: {r:?}"); + } + let mut doc = page_with_forms("BT /F1 14 Tf ET /Body Do 10 10 m 20 20 l S", &form); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap(); +} + /// 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() { diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index 744b7e8df5..52703d0c44 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -77,24 +77,12 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result pending_path = true, - b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => pending_path = false, - b"BT" => in_text = true, - b"ET" => in_text = false, - _ => {} - } let Some(image) = images.get(&at) else { continue }; if image.object.is_some_and(|o| settled.contains(&o)) { continue; } - // The resource name exactly as the `Do` writes it (its last operand, the one drawn): - // 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); // An earlier placement is read again only when it was refused for a rename that the // names merged since have made unnecessary. The last pass opens nothing, so there it // keeps its reason. @@ -102,7 +90,9 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result 64 { return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); } } - let translucent = ungroup::translucent_at(&next, &merged, &ops, at); - match ungroup::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { + match ungroup::ungroup_ops(&next, &merged, &ops, at, space.as_ref(), Matrix(image.matrix), &mut decode) { Ok(ungroup::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. diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index ed7ba908a7..f0e7dc0ebe 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -89,16 +89,16 @@ pub(crate) struct Refusal { pub(crate) renaming: 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 -/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). -/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, -/// then the CTM there). The form, and the forms and patterns it draws, decode within `budget`. +/// Ungroup the form drawn by the `Do` at `page_ops[at]` (the page's streams parsed as one, the +/// indexes `page_images` gives) from the page's `resources`. `page_space` is the page group's +/// blending colour space ([`page_blend_space`]). `placement` maps the form's space to the page's +/// default space at the `Do` (its `/Matrix`, then the CTM there). The form, and the forms and +/// patterns it draws, decode within `budget`. pub(crate) fn ungroup_ops( doc: &Document, resources: &Dict, - name: &[u8], - translucent: bool, + page_ops: &[Op], + at: usize, page_space: Option<&Object>, placement: Matrix, budget: &mut DecodeBudget, @@ -112,6 +112,19 @@ pub(crate) fn ungroup_ops( let refuse = |why: &str| refusal(why, false); // The form itself is the reason: every placement of it is refused the same way. let invalid = |why: &str| refusal(why, true); + let op = page_ops.get(at).filter(|op| op.is("Do")).ok_or_else(|| refuse("it is missing"))?; + // The renderer draws a `Do`'s last operand, and the placement was read for its first + // (`page_images`): with more than one they can be different forms. + if op.operands.len() != 1 { + return Err(refuse("it is drawn with an operand too many")); + } + let name = op.name(0).ok_or_else(|| refuse("it is missing"))?; + let (translucent, open) = state_at(doc, resources, page_ops, at); + // Inline, its clip would take in the page's unfinished path and its text objects would be + // nested in the page's. + if open { + return Err(refuse("it is drawn inside an unfinished path or text object")); + } let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; @@ -680,16 +693,22 @@ fn blends(doc: &Document, gs: &Dict) -> bool { !first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible"))) } -/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's -/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs` -/// changes only the parameters it sets. -pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool { +/// The state at op `at` of the page's streams parsed as one: whether the graphics state is +/// translucent, blended or soft-masked there, following `q`/`Q` and `gs` (each `gs` changes only +/// the parameters it sets), which a transparency group composites as a whole; and whether a path +/// or a text object is open there. +fn state_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> (bool, bool) { let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); // Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal. let mut stack: Vec<[bool; 4]> = Vec::new(); let mut now = [false; 4]; + let (mut path, mut text) = (false, false); for op in ops.iter().take(at) { match op.op.as_slice() { + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => path = true, + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => path = false, + b"BT" => text = true, + b"ET" => text = false, b"q" => stack.push(now), // A Q with nothing to restore is ignored, as renderers ignore it. b"Q" => now = stack.pop().unwrap_or(now), @@ -715,5 +734,5 @@ pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: u _ => {} } } - now.iter().any(|v| *v) + (now.iter().any(|v| *v), path || text) } From cc9f7977bf1f8855aeb2f8957ed799c26aeae4f7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 21:48:04 +0700 Subject: [PATCH 11/21] Repair a page's damaged THSarabunPSK map without grouped text The repair reads the page's own fonts. It ran only after a group was expanded, so the same damaged map on text that was never grouped kept reading wrong. Preparation now repairs it whenever it finds it, recorded in the repair log as before, and PrepareText (text_prepare) succeeds when it repaired a map even with nothing to expand. A repaired map no longer matches the damage's fingerprint, so preparing again changes nothing. --- crates/automation/README.md | 5 +++-- crates/automation/src/tools.rs | 2 +- crates/edit/src/tests.rs | 24 ++++++++++++++++++++++++ crates/edit/src/text/forms.rs | 21 +++++++++++++-------- crates/engine/src/edit/run.rs | 2 +- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/automation/README.md b/crates/automation/README.md index c6a5ad4e3d..e87495cfd7 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -229,8 +229,9 @@ summary it returns what this edit did: | `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. +The page's own fonts with that damaged map are repaired too, whether their text was grouped or +not. It fails, changing nothing, when the page has nothing to prepare or repair (the error gives +the first reason a group was left as it was) or the page is out of range. ### Superscript and subscript normalization diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9eb81425cf..4f9863de59 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1591,7 +1591,7 @@ pub fn tools() -> Vec { 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 (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.") + 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). Also repairs the page's own damaged legacy Thai maps, grouped or not. Fails when the page has nothing to prepare or repair. 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/edit/src/tests.rs b/crates/edit/src/tests.rs index a309d962ab..8faa62b766 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13023,6 +13023,30 @@ fn grouped_legacy_thai_map_repair_follows_every_block_of_the_damaged_map() { assert!(lines.iter().any(|l| l.text == "ฮา"), "{:?}", lines.iter().map(|l| &l.text).collect::>()); } +/// The damaged THSarabunPSK map of text that was never grouped is repaired by preparation too, +/// recorded in the repair log; with nothing to prepare or repair, preparation changes nothing. +#[test] +fn legacy_thai_map_repair_needs_no_grouped_text() { + 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"; + // ฮา: codes 259 and 263, drawn by the page itself. + let mut doc = page_with_forms("BT /F1 14 Tf 40 500 Td <01030107> Tj ET", &[]); + 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), (0, 1)); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "ฮา")); + assert!(doc.repair_log().iter().any(|l| l.contains("THSarabunPSK")), "{:?}", doc.repair_log()); + // Repaired once: the map no longer has the damage, and nothing else is to be done. + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().repaired_fonts, 0); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); +} + /// 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. diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index 52703d0c44..f6d7f412a1 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -33,7 +33,8 @@ struct Inlined { waiting: Vec<(usize, String)>, } -/// Prepare grouped source text atomically, within eight levels and 64 placements per page. +/// Prepare grouped source text atomically, within eight levels and 64 placements per page, and +/// repair the page fonts' damaged legacy Thai maps (`repair_thai_fonts`), grouped or not. /// Shared forms and other pages are untouched. Preparation is a distinct undoable engine edit. pub fn prepare(doc: &mut Document, page: usize) -> Result { let mut next = doc.clone(); @@ -179,16 +180,20 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result 0 { + // The page's own damaged maps are repaired too, those of text that was never grouped + // included: its text reads wrong all the same. + result.repaired_fonts = repair_thai_fonts(&mut next, page)?; + if result.expanded > 0 || result.repaired_fonts > 0 { for reason in &result.refused { next.note_repair(format!("Grouped text left intact: {reason}")); } - result.repaired_fonts = repair_thai_fonts(&mut next, page)?; - next.note_repair(format!( - "Prepared {} grouped text placement(s) on page {} for direct source-text editing", - result.expanded, - page.saturating_add(1) - )); + if result.expanded > 0 { + next.note_repair(format!( + "Prepared {} grouped text placement(s) on page {} for direct source-text editing", + result.expanded, + page.saturating_add(1) + )); + } *doc = next; } Ok(result) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 2979f7ee7b..82a302d769 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -734,7 +734,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::PrepareText { page } => { let result = pdfcraft_edit::text::forms::prepare(doc, *page)?; - if result.expanded == 0 { + if result.expanded == 0 && result.repaired_fonts == 0 { return Err(EditError::Invalid( result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()), )); From ae7a21d2b0503fe7a50ba1cdd521d7be58c962cc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 22:02:38 +0700 Subject: [PATCH 12/21] Edit grouped text with one click instead of preparing pages on sight Edit text queued Prepare grouped text for every page that came into view, after a full preparation on a copy of the document on the UI thread to see whether it could. Scrolling cost that work per page, marked the document changed, added undo steps and inlined shared forms although nothing was edited. Now nothing happens while pages are shown. A click (press and release, no drag) on grouped artwork, routed to the picture by the page router as before, asks once whether that artwork shows text preparation would open (text::forms::grouped_text_at, which reads the page without copying or changing it). If it does, the page is prepared as one undoable step and the paragraph at the point clicked opens for typing. A logo or a group preparation would refuse is selected as before, with no message and no change. A drag still moves the selected group, and while a paragraph is open or an edit is waiting the click selects as before. text_prepare is unchanged. The docs and the parity entry no longer say text is prepared on entering Edit text. --- crates/automation/README.md | 4 +- crates/edit/src/tests.rs | 28 +++++ crates/edit/src/text/forms.rs | 27 ++++- crates/engine/src/document.rs | 19 ++-- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/canvas/page_input.rs | 9 +- crates/ui-egui/src/tools/edit_text_ui.rs | 10 ++ .../ui-egui/src/tools/edit_text_ui/image.rs | 33 ++++-- crates/ui-egui/tests/it/editing.rs | 106 +++++++++++++++--- docs/editing-existing-text.md | 36 +++--- parity/acrobat-features.toml | 4 +- 11 files changed, 215 insertions(+), 69 deletions(-) diff --git a/crates/automation/README.md b/crates/automation/README.md index e87495cfd7..937dfecb7d 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -217,8 +217,8 @@ the listing's 32 MiB preview budget can leave it out too; `text_edit` still acce ### 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 +`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click +on grouped text in the GUI's Edit text 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: diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 8faa62b766..4e290a8178 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12816,6 +12816,34 @@ fn grouped_image_artwork_is_not_prepared() { assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); } +/// A click on grouped artwork prepares its page only when that artwork has text preparation +/// would open: a text group does, a logo, a refused group and a picture don't, and asking reads +/// the page without changing it. +#[test] +fn grouped_text_at_says_which_artwork_preparation_would_open() { + let forms = [ + ("Body".to_string(), "BT /F1 14 Tf 40 500 Td (body) Tj ET".to_string()), + ("Logo".to_string(), "q 10 0 0 10 0 0 cm /Im Do Q".to_string()), + ("Knock".to_string(), "BT /F1 14 Tf 40 400 Td (kept) Tj ET".to_string()), + ]; + let mut doc = page_with_forms("/Body Do /Logo Do /Knock Do q 10 0 0 10 0 0 cm /Im Do Q", &forms); + let r = pdfcraft_cos::ObjRef::new(9, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + s.dict.set( + b"Group".to_vec(), + Object::Dict(build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone()), + ); + doc.set(r, Object::Stream(s)); + let before = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let images = images::page_images(&doc, 0).unwrap(); + let at = |name: &str| images.iter().position(|i| i.name == name).unwrap(); + let says = |name: &str| text::forms::grouped_text_at(&doc, 0, at(name)).unwrap(); + assert!(says("Body")); + assert!(!says("Logo") && !says("Knock") && !says("Im")); + assert!(!text::forms::grouped_text_at(&doc, 0, images.len()).unwrap(), "no such picture"); + assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before); +} + /// Artwork and groups refused for what they are (knockout groups, content that restores more /// graphics states than it saves) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text /// group are 41 reads, within the 64-placement limit, where reading either again on the second diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index f6d7f412a1..d51392a0e2 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -22,6 +22,9 @@ pub struct Preparation { /// grouped and is reported in [`Preparation::refused`]. const MAX_LEVELS: usize = 8; +/// What preparing one page may decode, and add to its content, in all. +const MAX_PREPARED_BYTES: usize = 16 << 20; + /// What one pass of [`prepare`] inlined, as positions in the next pass's parse of the page. struct Inlined { /// How many operators that parse has; with any other count the positions are wrong. @@ -39,9 +42,9 @@ struct Inlined { pub fn prepare(doc: &mut Document, page: usize) -> Result { let mut next = doc.clone(); let mut result = Preparation::default(); - let mut bytes_left = 16usize << 20; + let mut bytes_left = MAX_PREPARED_BYTES; let mut visits = 0usize; - let mut decode = pdfcraft_cos::DecodeBudget::new(16 << 20, 0); + let mut decode = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0); // Forms found to be artwork, or refused for a reason in the form itself (`Refusal::intrinsic`): // a placement of one that an expanded group adds is left as it is without being read or // counted, so logos drawn inside many text groups don't use up the limit. A form refused only @@ -199,6 +202,26 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result { + let Some(img) = crate::page_images(doc, page)?.into_iter().nth(image).filter(|i| i.is_form) else { return Ok(false) }; + let p = page_dict(doc, page)?; + let parsed = joined(&strict_streams(doc, &p.dict)?).parse(); + if parsed.skipped > 0 { + return Ok(false); + } + let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let space = ungroup::page_blend_space(doc, &p.dict); + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0); + Ok(match ungroup::ungroup_ops(doc, &resources, &parsed.ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget) { + Ok(u) => u.shows_text == Some(true), + Err(r) => r.renaming, + }) +} + /// A font is copied into this page: repairing the map must never alter a shared form/font. fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result { let p = page_dict(doc, page)?; diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index eb28a92fe0..22667377c3 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -260,18 +260,13 @@ impl Document { pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) } - /// Whether this page has grouped source text that can be prepared without changing its appearance. - /// Used once when entering the existing-text tool, before queuing an undoable preparation. - pub fn can_prepare_text(&self, page: usize) -> bool { - self.text_preparation(page).is_ok_and(|p| p.expanded > 0) - } - - /// Preview preparation, including skipped groups and character-map repairs, without editing. - pub fn text_preparation(&self, page: usize) -> Result { - let editor = self.editor.as_ref().ok_or("the document is not editable")?; - crate::guard(|| pdfcraft_edit::text::forms::prepare(&mut editor.cos.clone(), page)) - .map_err(|e| format!("grouped text could not be prepared safely ({e})"))? - .map_err(|e| e.to_string()) + /// Edit text: whether grouped artwork `index` on `page` (0-based, as [`Self::page_images`] + /// lists them) shows text that [`Edit::PrepareText`](crate::Edit::PrepareText) would make + /// editable. Reads the page without changing it; `false` for a logo, a group that can't be + /// opened or a page that can't be read. + pub fn grouped_text_at(&self, page: usize, index: usize) -> bool { + let Some(editor) = self.editor.as_ref() else { return false }; + crate::guard(|| pdfcraft_edit::text::forms::grouped_text_at(&editor.cos, page, index)).is_ok_and(|r| r.is_ok_and(|yes| yes)) } /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ace0f99e5..8ba3e0fcdb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -658,8 +658,10 @@ pub struct DocView { /// Edit text: the lines per page (with the document generation they were read at), and the /// line being edited. pub(crate) edit_lines: HashMap)>, - /// Pages checked for grouped source text during this Edit text activation. - pub(crate) prepared_text_pages: std::collections::HashSet, + /// Edit text: a click on grouped artwork with text queued [`Edit::PrepareText`] for its page: + /// the page and the point clicked (user space), whose paragraph opens once the page is + /// prepared, so one click on grouped text starts editing it. + pub(crate) grouped_text_click: Option<(usize, [f64; 2])>, pub line_editor: Option, /// The paragraph editor whose edit was just queued. It opens again with its typing if the /// edit fails, as a refused form field does. @@ -1061,7 +1063,7 @@ impl DocView { organize_range: Default::default(), pending_edit: None, edit_lines: HashMap::new(), - prepared_text_pages: Default::default(), + grouped_text_click: None, line_editor: None, applied_editor: None, pending_text_open: None, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f48e6e6ea1..940eca080b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -76,13 +76,6 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let (lines, images) = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - if tool != QuickTool::EditText { - view.prepared_text_pages.clear(); - } else if can_modify && view.pending_edit.is_none() && view.line_editor.is_none() && view.prepared_text_pages.insert(i) && doc.can_prepare_text(i) - { - view.pending_edit = Some(pdfcraft_engine::Edit::PrepareText { page: i }); - ui.ctx().request_repaint(); - } let field_tool = match tool { QuickTool::Field(f) => Some(f), _ => None, @@ -226,7 +219,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let adding = (tool == QuickTool::AddText).then_some(added.as_slice()); let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding }; let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view); - let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate }; + let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, doc, info, press, gate }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); text_used || image_used }; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f2833de356..2413881c0e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -707,6 +707,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text None => view.pending_text_open = None, } } + // A click on grouped text prepared the page (`image_input`): the paragraph now at the point + // clicked opens, and none if the point is on the group's artwork. A preparation that failed + // left the page as it was, where no paragraph is at that point. It is taken either way. + if let Some((_, [x, y])) = view.grouped_text_click.take_if(|(p, _)| *p == page) + && gate + && active.is_none() + && let Some((block, _)) = grab_at(&boxes, user_to_screen(xf, info, page, x, y), false) + { + open_editor(cx, lines, &boxes, block, view); + } if !gate { return false; } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 6f0187f417..61b8fa866f 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -12,6 +12,7 @@ pub(crate) struct ImageCx<'a> { pub xf: &'a PageXform, pub ptr: &'a PagePointer, pub page: usize, + pub doc: &'a pdfcraft_engine::Document, pub info: &'a DocInfo, /// What the press the pages hold is to this page. pub press: PagePress, @@ -268,7 +269,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { /// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, gate, .. } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -686,7 +687,7 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { /// pointer was used. #[allow(clippy::cognitive_complexity)] pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx; + let ImageCx { ui, resp, xf, ptr, page, doc, info, press, gate } = *cx; let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -977,18 +978,32 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - // A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this - // page leaves it unselected (where the page's objects can be selected together, the area - // selection the press starts has done so already). + // A click selects the picture its press was on; its cursor is the hit's. One on grouped + // artwork with text prepares the page instead (one undoable step), and the paragraph clicked + // opens once it has (`edit_text_ui::page_input`): a drag still moves the group selected. Not + // while an edit is queued or a paragraph is open, whose typing that edit would overtake. One + // nobody holds on this page leaves the picture unselected (where the page's objects can be + // selected together, the area selection the press starts has done so already). let owner = match press { PagePress::Mine(Owner::Picture { index, .. }) => Some(index), _ => None, }; if gate && resp.clicked() { - match owner { - Some(index) => view.image_selection = Some(ImageSelection::at(page, index)), - None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, - None => {} + // Where grouped artwork with text was clicked; the page is read for it once, on the click. + let grouped_text = owner + .filter(|i| images.get(*i).is_some_and(|im| im.is_form) && view.pending_edit.is_none() && view.line_editor.is_none()) + .zip(ptr.released.or(ptr.origin)) + .filter(|(i, _)| doc.grouped_text_at(page, *i)); + match (owner, grouped_text) { + (_, Some((_, at))) => { + let (x, y) = screen_to_user(xf, info, page, at); + view.image_selection = None; + view.pending_edit = Some(Edit::PrepareText { page }); + view.grouped_text_click = Some((page, [x, y])); + } + (Some(index), None) => view.image_selection = Some(ImageSelection::at(page, index)), + (None, _) if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, + (None, _) => {} } } owner.is_some() diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 24f46ac6a1..4f3e15bd5d 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -10464,29 +10464,18 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() { #[test] fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() { let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET"; - let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(); let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(), - stream("<<", b"q /Fm1 Do Q"), - stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table), + pdf_stream("<<", b"q /Fm1 Do Q"), + pdf_stream( + "<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", + table, + ), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), ]; - let mut pdf = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - pdf.extend_from_slice(o); - pdf.extend_from_slice(b"\nendobj\n"); - } - let xref = pdf.len(); - pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let pdf = pdf_of_objects(&objs); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); @@ -10521,6 +10510,89 @@ fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() { assert_eq!(blocks(&h), 0, "undo puts the form back"); } +/// A stream object `dict` (its opening `<<` and entries, without `/Length` or `>>`) holding `data`. +fn pdf_stream(dict: &str, data: &[u8]) -> Vec { + [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat() +} + +/// A PDF of `objs`, numbered from 1, with the first as its catalog. +fn pdf_of_objects(objs: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// Grouped text is not prepared while the pages are only looked at: entering Edit text and +/// scrolling change nothing. One click on the group's text prepares that page, as one undoable +/// step, and opens the paragraph clicked for typing. A click on a grouped logo selects it, as a +/// click on any picture does, and changes nothing; a drag from the text group prepares nothing. +#[test] +fn one_click_on_grouped_text_opens_it_for_editing() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Body 5 0 R /Logo 7 0 R >> >> >>".to_vec(), + pdf_stream("<<", b"q /Body Do Q q 1 0 0 1 300 20 cm /Logo Do Q"), + pdf_stream( + "<< /Type /XObject /Subtype /Form /BBox [0 0 280 300] /Resources << /Font << /F1 6 0 R >> >>", + b"BT /F1 24 Tf 1 0 0 1 40 200 Tm (Grouped words) Tj ET", + ), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + pdf_stream("<< /Type /XObject /Subtype /Form /BBox [0 0 60 60] /Resources << >>", b"0 0 1 rg 0 0 60 60 re f"), + ]; + let pdf = pdf_of_objects(&objs); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("grouped.pdf", None, pdf.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let doc = |h: &Harness<'static, PdfCraftApp>| { + let d = h.state().session.get(id).unwrap(); + (d.text_blocks(0).len(), d.page_images(0).len(), d.can_undo().map(str::to_string)) + }; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert_eq!(doc(&h), (0, 2, None), "nothing is prepared while the page is only shown"); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // User space (x, y) on the page, which is 400 by 300 points and upright. + let at = |x: f32, y: f32| r.left_top() + egui::vec2(r.width() * x / 400.0, r.height() * (300.0 - y) / 300.0); + + click_at(&mut h, at(330.0, 50.0)); + let logo = h.state().session.get(id).unwrap().page_images(0).iter().position(|i| i.name == "Logo"); + assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)), logo.map(|i| (0, i)), "the logo is selected"); + assert_eq!(doc(&h), (0, 2, None), "a click on artwork without text changes nothing"); + assert!(h.state().views[0].line_editor.is_none()); + + drag(&mut h, at(60.0, 208.0), at(120.0, 150.0)); + assert_eq!(doc(&h).0, 0, "a drag on the text group prepares nothing"); + + click_at(&mut h, at(60.0, 208.0)); + h.run_steps(2); + assert_eq!(doc(&h), (1, 1, Some("Prepare grouped text".into())), "the page's grouped text is prepared, in one step"); + let editing = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(editing.as_deref(), Some("Grouped words"), "the paragraph clicked opens for typing"); + + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + let (blocks, images, _) = doc(&h); + assert_eq!((blocks, images), (0, 2), "undo puts the group back"); +} + #[test] fn outward_image_grip_drags_grow_on_rotated_pages() { for rotation in [0, 90, 180, 270] { diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 6127f9a234..d8e8ec44d2 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -1,20 +1,28 @@ # Editing existing text and source subsets -Entering **Edit text and images** prepares supported grouped Form XObjects on the visible -page in an undoable **Prepare grouped text** step. The group's original operators, clip, -placement and resources become page content; shared forms on other pages stay unchanged. -Headless callers use `text_prepare`, then list `text_paragraphs` and call `text_edit`. +In **Edit text and images**, text drawn inside a Form XObject (grouped artwork) is edited +with one click: clicking its text prepares that page in an undoable **Prepare grouped text** +step and opens the paragraph clicked for typing. Nothing is prepared while pages are only +shown or scrolled. A drag still moves the selected group as a whole; a click on grouped +artwork that preparation wouldn't open (a logo, a refused group) selects it as before, and +changes nothing. To select a text group for moving, right-click it. While a paragraph is open +or another edit is waiting, the click selects the group instead, as before; the next click +prepares it. The group's original operators, clip, placement and resources become page +content; shared forms on other pages stay unchanged. Ungroup (the artwork's right-click menu) +draws one placement in place with the same code. 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, 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. +Preparation refuses knockout/blended groups, groups that blend in a colour space other than +the page group's (DeviceRGB on a page that names none), forms whose default +colour spaces differ from the page's, tagged forms, forms drawn inside text objects or +unfinished paths, groups whose nested content can't be checked 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 repair log records +them when the page is prepared. 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; `text_prepare` repairs it also for text that was never grouped, +and 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; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dcc07a1ec7..c108221d44 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/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", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads"] -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. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms whose /DefaultGray, /DefaultRGB or /DefaultCMYK the page doesn't share (ISO 32000-2 §8.6.5.6), each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +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", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, 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, also for text that was never grouped (text_prepare). 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. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" From d351aa2760e81ce67fe8f00b9d79dd4872e6ef5a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:23:09 +0700 Subject: [PATCH 13/21] Read Thai font programs and CIDToGIDMaps with DecodeParms given by reference A predictor whose parameters are an indirect object left its row tags in the decoded font program and glyph map, so the Thai map repair, the glyph codes and the shaper read the wrong bytes. Resolve the parameters as the other font readers do, keeping the decode budgets. --- crates/fonts/src/pdf.rs | 2 +- crates/fonts/src/pdf/thai.rs | 50 ++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index d093c22e69..2693c19701 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -108,7 +108,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; + let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 950cd9edb0..33932a6ca7 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -170,7 +170,7 @@ 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() + DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() } /// Source glyph positions and the original PDF character codes that draw them. Empty codes @@ -197,7 +197,7 @@ pub(super) fn composite_glyph_maps( let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { None => None, Some(Object::Name(n)) if n == b"Identity" => None, - Some(Object::Stream(s)) => Some(budget.decode_within(s, 1 << 17).ok()?), + Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?), _ => return None, }; let face = skrifa::FontRef::new(bytes).ok()?; @@ -473,12 +473,17 @@ mod tests { /// A THSarabunPSK Identity-H font over `program`, with `to_unicode`. fn thai_font(doc: &mut Document, program: &[u8], to_unicode: &[u8]) -> Dict { + let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program)))); + thai_font_over(doc, file, Object::name("Identity"), to_unicode) + } + + /// [`thai_font`] over the `/FontFile2` stream `file` and the `/CIDToGIDMap` `cid_to_gid`. + fn thai_font_over(doc: &mut Document, file: Object, cid_to_gid: Object, 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)); + descriptor.set(b"FontFile2".to_vec(), file); let mut desc = Dict::new(); - desc.set(b"CIDToGIDMap".to_vec(), Object::name("Identity")); + desc.set(b"CIDToGIDMap".to_vec(), cid_to_gid); desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); let mut font = Dict::new(); font.set(b"Subtype".to_vec(), Object::name("Type0")); @@ -489,6 +494,22 @@ mod tests { font } + /// `data` as a Flate stream under a PNG predictor whose `/DecodeParms` is given by reference: + /// read without that dictionary, each row's tag byte stays in the decoded bytes. + fn predicted(doc: &mut Document, data: &[u8]) -> Object { + let rows: Vec = data.iter().flat_map(|byte| [0, *byte]).collect(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(1)); + let parms = doc.add(Object::Dict(parms)); + let mut stream = Stream::flate(Dict::new(), &rows); + stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + Object::Ref(doc.add(Object::Stream(stream))) + } + + /// An intact legacy map: nominal Thai and two tone alternates. + const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + /// 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 \ @@ -543,6 +564,25 @@ mod tests { program } + /// The font program and `/CIDToGIDMap` are read with `/DecodeParms` given by reference, as a + /// viewer reads them: the map repair, the glyph codes and the shaper see the real bytes. + #[test] + fn thai_font_streams_are_read_with_decode_parms_given_by_reference() { + let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]); + let mut doc = Document::new_empty(); + let program = predicted(&mut doc, &stripped); + let damaged = thai_font_over(&mut doc, program.clone(), Object::name("Identity"), DAMAGED); + assert!(recovered_thai_map(&doc, &damaged).is_some(), "the program is read with its predictor"); + let gids: Vec = (0u16..=368).flat_map(u16::to_be_bytes).collect(); + let map = predicted(&mut doc, &gids); + let font = thai_font_over(&mut doc, program, map, INTACT); + let metrics = Metrics::from_dict(&doc, &font); + let (map, _) = composite_glyph_maps(&doc, &font, &stripped, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap(); + assert_eq!(map.get(&0x0e01), Some(&214), "the CIDToGIDMap is read with its predictor"); + let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); + } + /// 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. From dff6c2cc3ef878cdbe57be55ca25fb641d89aaab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:25:15 +0700 Subject: [PATCH 14/21] Reach a THSarabunPSK subset's Thai glyphs when its own cmap is Latin-only A legacy subset can keep a cmap of its own that maps only Latin, so the editor program returned it unchanged and an edit such as a consonant with a tone mark failed although the subset keeps both outlines. Add the PDF's verified Thai and alternate mappings to the editor-only cmap where the program's own lacks them, keeping its existing mappings. --- crates/fonts/README.md | 3 ++- crates/fonts/src/pdf.rs | 20 +++++++++++++++++++- crates/fonts/src/pdf/thai.rs | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/fonts/README.md b/crates/fonts/README.md index c462c428d4..963ee889b9 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -88,7 +88,8 @@ exact program preservation, Unicode/CID/GID mapping and full-face subset complet never exported. See [existing-text editing](../../docs/editing-existing-text.md). `pdf::embedded_sfnt_within` reconstructs a missing Unicode cmap for Identity-H TrueType -subsets from the PDF's ToUnicode and CID-to-glyph maps. Only the in-memory editor copy changes; +subsets from the PDF's ToUnicode and CID-to-glyph maps, and completes a THSarabunPSK subset's +Latin-only one with them. Only the in-memory editor copy changes; the PDF's font program stays intact. `pdf::LegacyThaiFont` shapes verified THSarabunPSK positional glyphs into their original PDF codes, retaining the source face and weight. Missing contextual glyphs are refused. `pdf::recovered_thai_map` recognizes narrowly fingerprinted diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 2693c19701..0ef49c81a5 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -113,7 +113,11 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; } - if face.charmap().mappings().next().is_some() { + let own_cmap = face.charmap().mappings().next().is_some(); + // A legacy THSarabunPSK subset may keep a Latin-only cmap beside its Thai glyphs, which + // only the PDF's verified maps reach (`composite_glyph_maps`); other programs keep theirs. + let base_font = font.name(b"BaseFont").map(String::from_utf8_lossy).unwrap_or_default(); + if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) { return Some(bytes); } // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only @@ -124,6 +128,20 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { mappings = map; } + if own_cmap { + let charmap = face.charmap(); + if mappings.keys().all(|&u| char::from_u32(u).is_some_and(|c| charmap.map(c).is_some())) { + return Some(bytes); + } + // The program's own mappings stay; the PDF's fill only what they lack. + mappings.extend(charmap.mappings().filter(|(_, g)| g.to_u32() != 0).take(65_537).map(|(u, g)| (u, g.to_u32()))); + let mut merged = bytes.clone(); + let usable = append_editor_cmap(&mut merged, &mappings).is_some() + && skrifa::FontRef::new(&merged).is_ok_and(|font| font.charmap().mappings().next().is_some()); + return Some(if usable { merged } else { bytes }); + } + } else if own_cmap { + return Some(bytes); } else if let Ok(cmap) = face.cmap() { for code in 0..=255 { let Some(text) = metrics.unicode.text(code) else { continue }; diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 33932a6ca7..9ec2ff738b 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -583,6 +583,21 @@ mod tests { assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); } + /// A legacy subset that kept a Latin-only cmap of its own still reaches its Thai glyphs through + /// the PDF's verified maps, and keeps its own mappings. + #[test] + fn a_latin_only_cmap_does_not_hide_a_legacy_subsets_thai_glyphs() { + use skrifa::MetadataProvider as _; + let program = without_tables(&thai_ordered_program(), &[b"GSUB", b"GPOS"]); + let latin = skrifa::FontRef::new(&program).unwrap().charmap().map('A').unwrap(); + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, &program, INTACT); + let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); + let editor = crate::pdf::embedded_sfnt(&doc, &font).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay"); + } + /// 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. From fbc2dc77a78780c047fa5aa51dbb0037d85779aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:31:04 +0700 Subject: [PATCH 15/21] Read each THSarabunPSK subset of an edit once, within one budget Every resource name of a page's Thai subsets decoded and kept its own copy of the font program, so many names of one large program could hold about 2 GiB for a single edit. Names of one font object now share its program, the subsets of an edit are decoded within a shared 64 MiB allowance, and an edit that needs more is refused with the reason, changing nothing. --- crates/edit/src/tests.rs | 41 ++++++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 48 +++++++++++++++++++++----- crates/fonts/src/pdf/thai.rs | 18 ++++++---- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4e290a8178..95bf528b09 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13112,6 +13112,47 @@ fn editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face() { assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// Every resource name of one THSarabunPSK font object shares its program, decoded once. Distinct +/// subsets of the face are read within one budget for the edit: past it the edit is refused with +/// the reason, and the document is unchanged. +#[test] +fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { + // Padding after the tables: the program reads the same, at 12 MiB. + let mut large = legacy_thai_program(); + large.resize(12 << 20, 0); + let fixture = |extra: &dyn Fn(&mut Document) -> Object| { + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &large, LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let names: Vec<_> = (0..8).map(|i| (format!("T{i}").into_bytes(), extra(&mut doc))).collect(); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + for (name, font) in names { + fonts.set(name, font); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + doc + }; + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + // Eight more names of F1's object: 108 MiB if each name decoded its own copy. + let mut aliases = fixture(&|_| Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))); + text::rewrite_block(&mut aliases, 0, 0, Some("กขค"), &style).expect("the aliases share one program"); + assert_eq!(text::text_lines(&reopen(&aliases), 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กขค"); + // Eight more font objects over the program: each is read, and together they are too many. + let mut distinct = fixture(&|doc| { + let font = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_ref().clone(); + Object::Ref(doc.add(font)) + }); + let before = pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(); + let error = text::rewrite_block(&mut distinct, 0, 0, Some("กขค"), &style).unwrap_err().to_string(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB") && error.contains("choose a replacement font"), "{error}"); + assert_eq!(pdfcraft_cos::write_full(&distinct, &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). diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 3042a80d71..2ab50083df 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -105,12 +105,19 @@ impl FontResolver<'_> { } struct LegacyFace { - glyphs: Option, + /// Shared by every resource name of one font object: its program is decoded and kept once. + glyphs: Option>, metrics: Metrics, /// Weight and slant ([`FontStyle::of`]). style: FontStyle, + /// The font object the resource names, when it is given by reference. + object: Option, } +/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets +/// (each at most 16 MiB): a page may name many of them. +const MAX_THAI_PROGRAMS: usize = 64 << 20; + struct PreparedCharacters { values: Vec, substituted: Option, @@ -155,6 +162,7 @@ impl CharacterPreparation<'_> { let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; let thai_choice = builtin_thai_choice(fonts); let mut thai = ThaiShaping::default(); + let mut thai_programs = pdfcraft_cos::DecodeBudget::new(MAX_THAI_PROGRAMS, 0); let mut chars = Vec::new(); let mut substituted = None; let mut fallback = FallbackBuilder::new(max_glyphs); @@ -197,7 +205,7 @@ impl CharacterPreparation<'_> { }), }; match chosen { - Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy)?, + Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy, &mut thai_programs)?, None => None, } } else { @@ -823,13 +831,15 @@ fn apply_range_styles<'a>( } /// Select one existing subset of the exact source face for a Thai span. Never replace its -/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. +/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. The +/// subsets' programs are read within `budget`, shared by the edit's spans. fn legacy_thai_font( doc: &Document, fonts: &Dict, source: &TextLine, span: &str, cache: &mut HashMap, + budget: &mut pdfcraft_cos::DecodeBudget, ) -> 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)) @@ -843,13 +853,33 @@ fn legacy_thai_font( (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) })); for name in candidates.into_iter().take(128) { - if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(name) { - let Some(font) = fonts.get(entry.key().as_bytes()).and_then(|f| doc.dict(f)) else { continue }; - let metrics = Metrics::from_dict(doc, &font); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics)).ok().flatten(); - let style = FontStyle::of(doc, &font, &metrics); - entry.insert(LegacyFace { glyphs, metrics, style }); + if cache.contains_key(&name) { + continue; } + let Some(resource) = fonts.get(name.as_bytes()) else { continue }; + let Some(font) = doc.dict(resource) else { continue }; + let object = resource.as_ref(); + let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { + Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), style: alias.style, object }, + None => { + let metrics = Metrics::from_dict(doc, &font); + let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics), budget) + .ok() + .flatten() + .map(std::rc::Rc::new); + let style = FontStyle::of(doc, &font, &metrics); + LegacyFace { glyphs, metrics, style, object } + } + }; + cache.insert(name, face); + } + // A subset left unread would count as unusable, silently changing which subset draws the text. + if budget.remaining() == 0 { + return Err(EditError::Invalid(format!( + "the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text", + source.base_font.rsplit('+').next().unwrap_or(&source.base_font), + MAX_THAI_PROGRAMS >> 20 + ))); } // 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`). diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 9ec2ff738b..8511472096 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -293,7 +293,10 @@ impl LegacyThaiFont { /// 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> { + /// + /// Its streams are decoded against `budget`, which the caller shares between every font it + /// reads: what they keep is the programs decoded and the editor cmaps added to them. + pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { let m = Metrics::from_dict(doc, font); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); @@ -301,10 +304,9 @@ impl LegacyThaiFont { if !space.is_finite() || space < 0.0 { 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 bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?; 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")?; + let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, budget).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 @@ -579,7 +581,8 @@ mod tests { let metrics = Metrics::from_dict(&doc, &font); let (map, _) = composite_glyph_maps(&doc, &font, &stripped, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap(); assert_eq!(map.get(&0x0e01), Some(&214), "the CIDToGIDMap is read with its predictor"); - let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + let shaped = + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap(); assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); } @@ -592,7 +595,8 @@ mod tests { let latin = skrifa::FontRef::new(&program).unwrap().charmap().map('A').unwrap(); let mut doc = Document::new_empty(); let font = thai_font(&mut doc, &program, INTACT); - let shaped = LegacyThaiFont::from_pdf(&doc, &font, 0.25).unwrap().expect("a legacy subset").shape("ก่").unwrap(); + let shaped = + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap(); assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [214, 352]); let editor = crate::pdf::embedded_sfnt(&doc, &font).unwrap(); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay"); @@ -608,7 +612,7 @@ mod tests { 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) + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).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"); From 5f356ade1228165c7b4be713a3fd992bf9926e93 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:32:46 +0700 Subject: [PATCH 16/21] Give an unencoded space in legacy Thai the source font's space fallback A space that no subset of the face encodes was drawn as a quarter-em gap, narrower than the text-space fallback the source font uses elsewhere. Keep each subset's source space with it and use that for the gap. --- crates/edit/src/tests.rs | 19 +++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 14 +++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 95bf528b09..31982f271d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13112,6 +13112,25 @@ fn editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face() { assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// A space no subset of the face encodes is the gap the source font's text-space fallback gives +/// (`source_space`, a third of an em here), as wrapping measures it everywhere else. +#[test] +fn an_unencoded_space_in_legacy_thai_takes_the_source_space_fallback() { + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("ก ก"), &style).unwrap(); + // After the first ก (500 units), the second starts 330 units further: the gap. + let gaps: Vec = pdfcraft_content::parse(&page_content_bytes(&doc, 0)) + .ops + .iter() + .filter(|op| op.is("TJ")) + .flat_map(|op| op.operands.first().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_f64)) + .collect(); + assert!(gaps.iter().any(|g| (g + 330.0).abs() < 0.01), "{gaps:?}"); +} + /// Every resource name of one THSarabunPSK font object shares its program, decoded once. Distinct /// subsets of the face are read within one budget for the edit: past it the edit is refused with /// the reason, and the document is unchanged. diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 2ab50083df..deccc9c09e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -112,6 +112,8 @@ struct LegacyFace { style: FontStyle, /// The font object the resource names, when it is given by reference. object: Option, + /// Its text-space fallback ([`source_space`]), in ems. + space: f64, } /// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets @@ -860,15 +862,13 @@ fn legacy_thai_font( let Some(font) = doc.dict(resource) else { continue }; let object = resource.as_ref(); let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { - Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), style: alias.style, object }, + Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias }, None => { let metrics = Metrics::from_dict(doc, &font); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, source_space(doc, fonts, &metrics), budget) - .ok() - .flatten() - .map(std::rc::Rc::new); + let space = source_space(doc, fonts, &metrics); + let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new); let style = FontStyle::of(doc, &font, &metrics); - LegacyFace { glyphs, metrics, style, object } + LegacyFace { glyphs, metrics, style, object, space } } }; cache.insert(name, face); @@ -996,7 +996,7 @@ fn shape_existing_thai(cache: &HashMap, primary: &str, text: let code = f.metrics.encode(" ")?; Some(f.metrics.width(f.metrics.codes(&code).first()?.0)) }) - .unwrap_or(0.25); + .unwrap_or(first.space); (primary, Vec::new(), width) } else { return Err(EditError::Invalid(format!( From f4d669b31f844b087eff0a780fbf10b596114a2d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:34:13 +0700 Subject: [PATCH 17/21] Pass over a complete Thai program that may not be embedded again The first program of the face that could shape a span was chosen even when its embedding permissions forbid embedding it, and the edit then failed while a later program of the same face could have been used. Check that a program may be embedded before choosing it. --- crates/edit/src/tests.rs | 27 ++++++++++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 5 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 31982f271d..e61bd87d3e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13280,6 +13280,33 @@ fn a_legacy_subset_is_completed_by_a_complete_program_it_cannot_reuse_in_place() assert!(shown_codes(&doc).iter().all(|(font, _)| font != "F1"), "no cluster split from the subset: {:?}", shown_codes(&doc)); } +/// A complete program whose embedding permissions forbid embedding it again is passed over for a +/// later complete program of the face that may be, rather than failing the edit. +#[test] +fn a_complete_program_that_may_not_be_embedded_is_passed_over() { + 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 restricted = complete.clone(); + let fs_type = sfnt_table(&restricted, b"OS/2") + 8; + restricted[fs_type..fs_type + 2].copy_from_slice(&2u16.to_be_bytes()); + 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 encoding = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"begincmap 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange endcmap", + ))); + for (object, program) in [(6, &restricted), (7, &complete)] { + let mut full = legacy_thai_font(&mut doc, "THSarabunPSK", program, LEGACY_THAI_UNICODE); + full.set(b"Encoding".to_vec(), Object::Ref(encoding)); + doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(full)); + } + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).expect("the program that may be embedded shapes the span"); + assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "กขค"); +} + /// 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. diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index deccc9c09e..133944d941 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1145,10 +1145,11 @@ impl FontStyle { } } -/// `text` shaped by `font`'s embedded program with its own layout, when the program covers it. +/// `text` shaped by `font`'s embedded program with its own layout, when the program covers it and +/// may be embedded again (`embed_mapping` refuses one that may not, so a later program is tried). 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) { + if pdfcraft_fonts::embedding::validate(&bytes).is_err() || !pdfcraft_fonts::embedding::covers(&bytes, text) { return None; } let shaped = pdfcraft_fonts::source_shaping::shape(&bytes, text).ok()?; From d0c56fdfee614328d31b351d24f52128b4f8dd46 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:37:23 +0700 Subject: [PATCH 18/21] Refuse the form whose merged resources pass the page's limit The resource limit was checked before a form's resources were merged into the page's, so one form could take the page past it and every later form was then refused, even one whose resources were all the page's already. Check the merged dictionary instead. --- crates/edit/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ crates/edit/src/ungroup.rs | 5 +++++ 2 files changed, 41 insertions(+) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e61bd87d3e..97127db949 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12720,6 +12720,42 @@ fn grouped_source_text_preparation_edits_original_operators_without_changing_sha assert_eq!(text::forms::prepare(&mut doc, 0).unwrap().expanded, 0); } +/// A text form whose resources would take the page's past the limit is refused itself, so a later +/// form whose resources are all the page's already is still prepared. +#[test] +fn grouped_text_preparation_refuses_the_form_that_would_pass_the_resource_limit() { + let mut doc = page_with_forms( + "/A Do /B Do", + &[("A".into(), "BT /X 14 Tf 40 500 Td (a) Tj ET".into()), ("B".into(), "BT /F1 14 Tf 40 400 Td (b) Tj ET".into())], + ); + let helvetica = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |page| { + let Some(Object::Dict(mut resources)) = page.get(b"Resources").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut fonts)) = resources.get(b"Font").cloned() else { panic!("fixture") }; + // With F1, the 4 096 the limit allows. + for i in 0..4095 { + fonts.set(format!("P{i}").into_bytes(), helvetica.clone()); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + }) + .unwrap(); + // A names Helvetica /X, which the page doesn't: merging it adds a name. B's /F1 is the page's. + for (object, name) in [(7, "X"), (8, "F1")] { + let Object::Stream(mut form) = doc.get(pdfcraft_cos::ObjRef::new(object, 0)).as_ref().clone() else { panic!("fixture") }; + let mut fonts = Dict::new(); + fonts.set(name.as_bytes().to_vec(), helvetica.clone()); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + form.dict.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Stream(form)); + } + let prepared = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(prepared.expanded, 1); + assert!(prepared.refused.len() == 1 && prepared.refused[0].starts_with("A:"), "{:?}", prepared.refused); + assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "b")); +} + #[test] fn grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact() { for (page_ops, form_ops, extra) in [ diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index f0e7dc0ebe..1b1916a1c8 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -240,6 +240,11 @@ pub(crate) fn ungroup_ops( mine.set(fresh.clone(), value.clone()); map.push((key.to_vec(), fresh)); } + // Counted after the merge: a page taken past the limit would refuse every later form, + // even one whose resources are all the page's already. + if mine.len() > MAX_RESOURCES { + return Err(refuse("too many resources")); + } merged.set(cat.to_vec(), Object::Dict(mine)); if !map.is_empty() { renames.push((cat, map)); From e8322a8fc21c9120214edb5c3d9f32b95d963309 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:39:44 +0700 Subject: [PATCH 19/21] Shape Thai digits and bare letters without mark positioning The source shaper refused any Thai text in a program without GPOS mark positioning, so a row of Thai digits failed although nothing in it is set on a base. Require the positioning only for text with a vowel or tone mark, or Sara Am. --- crates/fonts/src/pdf/thai.rs | 13 +++++++++++++ crates/fonts/src/source_shaping.rs | 4 +++- docs/editing-existing-text.md | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 8511472096..722dfdc0bf 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -602,6 +602,19 @@ mod tests { assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay"); } + /// Only Thai with a mark to set on its base needs the program's mark positioning: digits and + /// bare letters shape without it. + #[test] + fn thai_without_marks_shapes_without_mark_positioning() { + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, &without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]), INTACT); + let program = crate::pdf::embedded_sfnt(&doc, &font).unwrap(); + let shaped = crate::source_shaping::shape(&program, "๑๒๓กข").expect("nothing to position"); + assert_eq!(shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>(), [290, 291, 292, 214, 215]); + let error = crate::source_shaping::shape(&program, "กิ่").unwrap_err(); + assert!(error.contains("mark positioning"), "{error}"); + } + /// 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. diff --git a/crates/fonts/src/source_shaping.rs b/crates/fonts/src/source_shaping.rs index b1043ad994..71e682e66a 100644 --- a/crates/fonts/src/source_shaping.rs +++ b/crates/fonts/src/source_shaping.rs @@ -68,7 +68,9 @@ fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result Date: Sun, 8 Nov 2026 23:44:21 +0700 Subject: [PATCH 20/21] Let only a same-style complete program keep the bundled Thai face out A complete program of the source's family in another style, such as one its descriptor marks bold, stopped the bundled Regular Thai outlines from completing a Regular subset, and the text was then drawn in that bold program. Count only a program in the source's style, as the subset completion itself does. --- crates/edit/src/tests.rs | 32 ++++++++++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 12 +++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 97127db949..4b3b6633e4 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -12692,6 +12692,38 @@ fn a_bold_thai_subset_is_not_completed_with_the_regular_bundled_face() { assert_eq!(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// A complete program of the family in another style (bold, by its descriptor) can't complete a +/// Regular subset, so it doesn't keep the bundled Regular outlines from doing so: the text is not +/// drawn in the bold program instead. +#[test] +fn a_complete_program_in_another_style_does_not_keep_the_bundled_thai_face_out() { + if pdfcraft_fonts::document_thai_font().is_none() { + eprintln!( + "skipping a_complete_program_in_another_style_does_not_keep_the_bundled_thai_face_out: 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"))); + 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)); + }) + .unwrap(); + let complete = with_legacy_thai_cmap(include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()); + let mut bold = legacy_thai_font(&mut doc, "NotoSansThai", &complete, LEGACY_THAI_UNICODE); + let Some(Object::Array(mut descendants)) = bold.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)); + bold.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + doc.set(pdfcraft_cos::ObjRef::new(6, 0), Object::Dict(bold)); + let edited = text::rewrite_block(&mut doc, 0, 0, Some("ผู้ใช้"), &text::BlockStyle::default()).expect("completed with the bundled face"); + assert_eq!(edited.substituted.as_deref(), pdfcraft_fonts::document_thai_font().map(|f| f.family)); +} + #[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())]); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 133944d941..a17e4d8a5d 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1051,8 +1051,11 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: // 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) { + let style = fonts + .get(source.font.as_bytes()) + .and_then(|f| doc.dict(f)) + .map_or(FontStyle { bold: source.bold, italic: source.italic }, |f| FontStyle::of(doc, &f, &Metrics::from_dict(doc, &f))); + if style.bold || style.italic { return false; } if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) @@ -1063,9 +1066,12 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: } // A registered complete matching program also completes the source subset. // Do not replace it with bundled outlines merely because the original resource is a subset. + // Only one in the source's style does (`program_candidates`), so only that one counts here. let family = pdfcraft_fonts::embedding::face_key(&source.base_font); !fonts.iter().filter_map(|(_, font)| doc.dict(font)).any(|font| { - pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family + let metrics = Metrics::from_dict(doc, &font); + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == family + && FontStyle::of(doc, &font, &metrics) == style && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { pdfcraft_fonts::embedding::validate(&bytes).is_ok() && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) From 378ef84ea1ce2f80b3b4f9cc3aaaab52ffca87be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 8 Nov 2026 23:46:53 +0700 Subject: [PATCH 21/21] Keep a legacy Thai alternate out of the nominal mark's place A legacy THSarabunPSK subset whose map gives a shifted alternate (glyphs 342-368) as the nominal mark, its nominal glyph dropped, shaped that alternate wherever the nominal was needed, drawing the mark out of place. The shaping copy now reaches an alternate only through its presentation form, so text that needs the dropped nominal is refused instead. --- crates/fonts/src/pdf/thai.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 722dfdc0bf..ac151a5a18 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -304,7 +304,7 @@ impl LegacyThaiFont { if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } - let bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?; + let mut bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?; let positioned = crate::source_shaping::has_thai_layout(&bytes); let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, budget).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 @@ -321,7 +321,23 @@ impl LegacyThaiFont { 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 })) + if !usable { + return Ok(None); + } + if !positioned { + // Glyphs 342–368 are alternates moved for a context. Mapped as the nominal character + // (its glyph dropped), one would be drawn out of place where the nominal form is + // needed: the shaper reaches it only through its PUA form, and that text is refused. + use skrifa::MetadataProvider as _; + let alternate = |u: u32, g: skrifa::GlyphId| matches!(u, 0x0e01..=0x0e5b) && (342..=368).contains(&g.to_u32()); + let charmap = skrifa::FontRef::new(&bytes).map_err(|e| e.to_string())?.charmap(); + if charmap.mappings().any(|(u, g)| alternate(u, g)) { + let nominal: BTreeMap = + charmap.mappings().filter(|&(u, g)| g.to_u32() != 0 && !alternate(u, g)).take(65_537).map(|(u, g)| (u, g.to_u32())).collect(); + super::append_editor_cmap(&mut bytes, &nominal).ok_or("the Thai source glyph map cannot be rebuilt safely")?; + } + } + Ok(Some(Self { bytes, codes, space, positioned })) } /// Shape into glyphs that are already in this exact source face. @@ -615,6 +631,21 @@ mod tests { assert!(error.contains("mark positioning"), "{error}"); } + /// A legacy subset whose map gives a shifted alternate as the nominal mark (its nominal glyph + /// dropped) draws it only where that alternate belongs, and refuses text needing the nominal. + #[test] + fn a_legacy_alternate_is_not_drawn_as_the_nominal_mark() { + let mut program = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]); + drop_glyph(&mut program, 265); + let unicode = [INTACT, b" 1 beginbfchar <0157> <0e34> endbfchar"].concat(); + let mut doc = Document::new_empty(); + let font = thai_font(&mut doc, &program, &unicode); + let font = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset"); + let ids = |text: &str| font.shape(text).map(|run| run.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::>()); + assert_eq!(ids("ปิ"), Ok(vec![240, 343]), "after an ascender, the alternate is the form needed"); + assert!(ids("กิ").is_err(), "elsewhere the nominal ิ is needed, which the subset dropped"); + } + /// 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.