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.
This commit is contained in:
xullul authored and xullul committed 2026-11-07 12:26:35 +07:00
1 parent f42fda7342
commit 77cebccda2
5 files changed
+543 -19

No files matched your search

+7
View File
@@ -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.
+72 -17
View File
@@ -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<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> 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<u8>, 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<u8>, 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<u8>, mappings: &std::collections::BTreeMap
Some(())
}
/// Reserve a new cmap directory slot, retaining every original table payload.
fn insert_editor_cmap_slot(bytes: &mut Vec<u8>, mut count: usize, mut head: Option<usize>) -> Option<(usize, Option<usize>)> {
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};
+41
View File
@@ -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");
}
+372
View File
@@ -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<Vec<(u16, char)>> {
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<Vec<u8>>,
}
/// 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<u32, u32>, BTreeMap<u32, u16>)> {
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<u32> {
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<u8>,
codes: BTreeMap<u32, u16>,
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<Option<Self>, 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<ExistingThaiRun, String> {
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> <ffff> 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");
}
}
+51 -2
View File
@@ -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<ShapedRun, String> {
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<ShapedRun, String> {
shape_inner(bytes, text, true)
}
fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result<ShapedRun, String> {
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<ShapedRun, String> {
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<ShapedRun, String> {
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<ShapedRun, String> {
}
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())));
}
}