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] 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()))); + } +}