mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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:
1 parent
f42fda7342
commit
77cebccda2
5 files changed
+543
-19
No files matched your search
@@ -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
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user