use pdfboss_core::FastMap;
use std::sync::{Arc, Mutex, PoisonError};
#[cfg(test)]
use pdfboss_core::{block_on, Document, Immediate};
use pdfboss_core::{AsyncObjectSource, Dict, Matrix, Object};
use crate::cff::CffFont;
use crate::path::{PathBuilder, Subpath};
use crate::substitute::{FaceRequest, SubstituteProvider};
use crate::truetype::{Seg, TrueType};
use crate::type1::Type1Font;
use crate::GlyphPainting;
type FlatCache = FastMap<(u16, [u32; 4]), Arc<Vec<Subpath>>>;
const MAX_FLAT_CACHE: usize = 8192;
enum Outlines {
TrueType(TrueType),
Cff(CffFont),
Type1(Type1Font),
Substitute(TrueType),
}
enum GlyphKind {
Simple(Box<[u16; 256]>),
Cid(Option<Vec<u16>>),
}
struct WidthMap {
map: FastMap<u32, f32>,
default: f32,
declared: bool,
}
impl WidthMap {
fn get(&self, code: u32) -> Option<f32> {
self.declared
.then(|| self.map.get(&code).copied().unwrap_or(self.default))
}
}
pub(crate) struct GlyphFont {
outlines: Outlines,
kind: GlyphKind,
widths: WidthMap,
afm_widths: FastMap<u32, f32>,
outline_cache: Mutex<FastMap<u16, Arc<[Seg]>>>,
flat_cache: Mutex<FlatCache>,
}
fn lock_cache<T>(cache: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
cache.lock().unwrap_or_else(PoisonError::into_inner)
}
impl GlyphFont {
#[cfg(test)]
pub(crate) fn load(
doc: &Document,
font: &Dict,
painting: GlyphPainting,
provider: Option<&dyn SubstituteProvider>,
) -> Option<GlyphFont> {
block_on(Self::load_with(&Immediate(doc), font, painting, provider))
}
pub(crate) async fn load_with<S: AsyncObjectSource>(
src: &S,
font: &Dict,
painting: GlyphPainting,
provider: Option<&dyn SubstituteProvider>,
) -> Option<GlyphFont> {
match font.get_name("Subtype").map(|n| n.0.as_str()) {
Some("Type0") => load_type0(src, font, painting).await,
Some("TrueType") => match load_simple(src, font).await {
Some(f) => Some(f),
None => substitute_at_full(src, font, painting, provider).await,
},
Some("Type1") | Some("MMType1") if painting.paints_all_embedded() => {
match load_simple_type1_or_cff(src, font).await {
Some(f) => Some(f),
None => substitute_at_full(src, font, painting, provider).await,
}
}
_ => None,
}
}
pub(crate) fn two_byte(&self) -> bool {
matches!(self.kind, GlyphKind::Cid(_))
}
pub(crate) fn gid(&self, code: u32) -> u16 {
match &self.kind {
GlyphKind::Simple(table) => table[(code & 0xff) as usize],
GlyphKind::Cid(None) => code as u16,
GlyphKind::Cid(Some(map)) => map.get(code as usize).copied().unwrap_or(0),
}
}
pub(crate) fn outline(&self, gid: u16) -> Arc<[Seg]> {
if let Some(cached) = lock_cache(&self.outline_cache).get(&gid) {
return Arc::clone(cached);
}
let segs: Arc<[Seg]> = match &self.outlines {
Outlines::TrueType(tt) | Outlines::Substitute(tt) => tt.glyph_path(gid),
Outlines::Cff(cff) => cff.glyph_path(gid),
Outlines::Type1(t1) => t1.glyph_path(gid),
}
.into();
lock_cache(&self.outline_cache).insert(gid, Arc::clone(&segs));
segs
}
pub(crate) fn flattened(&self, gid: u16, linear: Matrix) -> Arc<Vec<Subpath>> {
let key = (
gid,
[
linear.a.to_bits(),
linear.b.to_bits(),
linear.c.to_bits(),
linear.d.to_bits(),
],
);
if let Some(cached) = lock_cache(&self.flat_cache).get(&key) {
return Arc::clone(cached);
}
let segs = self.outline(gid);
let polys = Arc::new(build_glyph(&segs, linear));
let mut cache = lock_cache(&self.flat_cache);
if cache.len() < MAX_FLAT_CACHE {
cache.insert(key, Arc::clone(&polys));
}
polys
}
pub(crate) fn advance(&self, code: u32) -> f32 {
if let Some(width_1000) = self.widths.get(code) {
return width_1000 / 1000.0 * self.units_per_em();
}
if let Some(&width_1000) = self.afm_widths.get(&code) {
return width_1000 / 1000.0 * self.units_per_em();
}
match &self.outlines {
Outlines::TrueType(tt) | Outlines::Substitute(tt) => {
f32::from(tt.advance(self.gid(code)))
}
Outlines::Cff(_) | Outlines::Type1(_) => 0.0,
}
}
pub(crate) fn units_per_em(&self) -> f32 {
match &self.outlines {
Outlines::TrueType(tt) | Outlines::Substitute(tt) => tt.units_per_em() as f32,
Outlines::Cff(cff) => cff.units_per_em(),
Outlines::Type1(t1) => t1.units_per_em(),
}
}
}
fn build_glyph(segs: &[Seg], to_device: Matrix) -> Vec<Subpath> {
let mut pb = PathBuilder::new(to_device);
for seg in segs {
match *seg {
Seg::Move(x, y) => pb.move_to(x, y),
Seg::Line(x, y) => pb.line_to(x, y),
Seg::Quad(cx, cy, x, y) => {
let p0 = pb.current_point();
let c1x = p0.x + 2.0 / 3.0 * (cx - p0.x);
let c1y = p0.y + 2.0 / 3.0 * (cy - p0.y);
let c2x = x + 2.0 / 3.0 * (cx - x);
let c2y = y + 2.0 / 3.0 * (cy - y);
pb.curve_to(c1x, c1y, c2x, c2y, x, y);
}
Seg::Cubic(c1x, c1y, c2x, c2y, x, y) => pb.curve_to(c1x, c1y, c2x, c2y, x, y),
Seg::Close => pb.close(),
}
}
pb.finish()
}
async fn load_simple<S: AsyncObjectSource>(src: &S, font: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, font.get("FontDescriptor")?).await?;
let program = stream_bytes(src, descriptor.get("FontFile2")?).await?;
let tt = TrueType::parse(program)?;
let base = base_encoding(src, font).await;
let diffs = differences(src, font).await;
let mut table = Box::new([0u16; 256]);
for (code, slot) in table.iter_mut().enumerate() {
let code = code as u8;
if let Some(name) = diffs.get(&code) {
if let Some(gid) = resolve_name(&tt, name) {
*slot = gid;
continue;
}
}
if let Some(ch) = base.and_then(|f| f(code)) {
if let Some(gid) = tt.gid_for_unicode(ch as u32).filter(|&g| g != 0) {
*slot = gid;
continue;
}
}
if tt.has_cmap() {
let cp = u32::from(code);
let mut gid = tt.gid_for_unicode(cp).unwrap_or(0);
if gid == 0 {
gid = tt.gid_for_unicode(0xF000 + cp).unwrap_or(0);
}
*slot = gid;
}
}
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::TrueType(tt),
kind: GlyphKind::Simple(table),
widths: simple_widths(src, font).await,
afm_widths: FastMap::default(),
})
}
async fn rv<S: AsyncObjectSource>(src: &S, dict: &Dict, key: &str) -> Option<Object> {
src.resolve(dict.get(key)?).await.ok()
}
async fn simple_widths<S: AsyncObjectSource>(src: &S, font: &Dict) -> WidthMap {
let first = rv(src, font, "FirstChar")
.await
.and_then(|o| o.as_int())
.unwrap_or(0)
.max(0) as u32;
let mut map = FastMap::default();
let mut declared = false;
if let Some(Object::Array(items)) = rv(src, font, "Widths").await {
declared = true;
for (i, item) in items.iter().enumerate() {
let Some(code) = first.checked_add(i as u32) else {
break; };
if let Some(w) = src.resolve(item).await.ok().and_then(|o| o.as_f64()) {
map.insert(code, w as f32);
}
}
}
let descriptor = rv(src, font, "FontDescriptor")
.await
.and_then(|o| o.as_dict().cloned());
let default = match &descriptor {
Some(fd) => rv(src, fd, "MissingWidth")
.await
.and_then(|o| o.as_f64())
.map_or(0.0, |w| w as f32),
None => 0.0,
};
WidthMap {
map,
default,
declared,
}
}
async fn load_cff_simple<S: AsyncObjectSource>(src: &S, font: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, font.get("FontDescriptor")?).await?;
let program = stream_bytes(src, descriptor.get("FontFile3")?).await?;
let cff = CffFont::parse(program)?;
let mut by_unicode: FastMap<char, u16> = FastMap::default();
for gid in 1..cff.num_glyphs() {
let gid = gid as u16;
let Some(name) = cff.name_for_gid(gid) else {
continue;
};
if let Some(ch) = pdfboss_encoding::glyph_to_unicode(&name) {
by_unicode.entry(ch).or_insert(gid);
}
}
let base = base_encoding(src, font).await;
let diffs = differences(src, font).await;
let mut table = Box::new([0u16; 256]);
for (code, slot) in table.iter_mut().enumerate() {
let code = code as u8;
if let Some(name) = diffs.get(&code) {
if let Some(gid) = cff.gid_for_name(name).filter(|&g| g != 0) {
*slot = gid;
continue;
}
}
if let Some(ch) = base.and_then(|f| f(code)) {
if let Some(&gid) = by_unicode.get(&ch) {
*slot = gid;
}
}
}
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::Cff(cff),
kind: GlyphKind::Simple(table),
widths: simple_widths(src, font).await,
afm_widths: FastMap::default(),
})
}
async fn load_simple_type1_or_cff<S: AsyncObjectSource>(src: &S, font: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, font.get("FontDescriptor")?).await?;
if descriptor.get("FontFile3").is_some() {
return load_cff_simple(src, font).await;
}
if descriptor.get("FontFile").is_some() {
return load_type1_simple(src, font).await;
}
None
}
async fn load_type1_simple<S: AsyncObjectSource>(src: &S, font: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, font.get("FontDescriptor")?).await?;
let program = stream_bytes(src, descriptor.get("FontFile")?).await?;
let t1 = Type1Font::parse(program)?;
let mut by_unicode: FastMap<char, u16> = FastMap::default();
for gid in 1..t1.num_glyphs() {
let gid = gid as u16;
let Some(name) = t1.name_for_gid(gid) else {
continue;
};
if let Some(ch) = pdfboss_encoding::glyph_to_unicode(name) {
by_unicode.entry(ch).or_insert(gid);
}
}
let base = base_encoding(src, font).await;
let diffs = differences(src, font).await;
let mut table = Box::new([0u16; 256]);
for (code, slot) in table.iter_mut().enumerate() {
let code = code as u8;
if let Some(name) = diffs.get(&code) {
if let Some(gid) = t1.gid_for_name(name).filter(|&g| g != 0) {
*slot = gid;
continue;
}
}
if let Some(ch) = base.and_then(|f| f(code)) {
if let Some(&gid) = by_unicode.get(&ch) {
*slot = gid;
continue;
}
}
if let Some(name) = t1.builtin_name(code) {
if let Some(gid) = t1.gid_for_name(name).filter(|&g| g != 0) {
*slot = gid;
}
}
}
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::Type1(t1),
kind: GlyphKind::Simple(table),
widths: simple_widths(src, font).await,
afm_widths: FastMap::default(),
})
}
fn resolve_name(tt: &TrueType, name: &str) -> Option<u16> {
if let Some(gid) = tt.gid_for_name(name).filter(|&g| g != 0) {
return Some(gid);
}
let ch = pdfboss_encoding::glyph_to_unicode(name)?;
tt.gid_for_unicode(ch as u32).filter(|&g| g != 0)
}
async fn base_encoding<S: AsyncObjectSource>(
src: &S,
font: &Dict,
) -> Option<fn(u8) -> Option<char>> {
let name = match rv(src, font, "Encoding").await {
Some(Object::Name(n)) => n.0,
Some(Object::Dict(d)) => d
.get_name("BaseEncoding")
.map(|n| n.0.clone())
.unwrap_or_else(|| "StandardEncoding".to_string()),
_ => return None,
};
Some(match name.as_str() {
"WinAnsiEncoding" => pdfboss_encoding::win_ansi,
"MacRomanEncoding" => pdfboss_encoding::mac_roman,
_ => pdfboss_encoding::standard,
})
}
pub(crate) async fn differences<S: AsyncObjectSource>(src: &S, font: &Dict) -> FastMap<u8, String> {
let mut out = FastMap::default();
let Some(Object::Dict(enc)) = rv(src, font, "Encoding").await else {
return out;
};
let Some(arr) = rv(src, &enc, "Differences").await else {
return out;
};
let Some(items) = arr.as_array() else {
return out;
};
let mut code: i64 = 0;
for item in items {
match item {
Object::Int(n) => code = *n,
Object::Name(name) => {
if (0..256).contains(&code) {
out.insert(code as u8, name.0.clone());
}
code = code.saturating_add(1);
}
_ => {}
}
}
out
}
async fn is_standard_encoding<S: AsyncObjectSource>(src: &S, font: &Dict) -> bool {
let name = match rv(src, font, "Encoding").await {
Some(Object::Name(n)) => n.0,
Some(Object::Dict(d)) => d
.get_name("BaseEncoding")
.map(|n| n.0.clone())
.unwrap_or_else(|| "StandardEncoding".to_string()),
_ => return true, };
!matches!(name.as_str(), "WinAnsiEncoding" | "MacRomanEncoding")
}
async fn substitute_at_full<S: AsyncObjectSource>(
src: &S,
font: &Dict,
painting: GlyphPainting,
provider: Option<&dyn SubstituteProvider>,
) -> Option<GlyphFont> {
if painting == GlyphPainting::Full {
if let Some(provider) = provider {
return load_substitute(src, font, provider).await;
}
}
None
}
async fn load_substitute<S: AsyncObjectSource>(
src: &S,
font: &Dict,
provider: &dyn SubstituteProvider,
) -> Option<GlyphFont> {
let req = FaceRequest::from_font_dict(src, font).await?;
let bytes = provider.face(&req)?;
let tt = TrueType::parse(bytes)?;
let base_font = font
.get_name("BaseFont")
.map(|n| n.0.as_str())
.unwrap_or("");
let base = base_encoding(src, font).await.or_else(|| {
pdfboss_encoding::is_standard_14(base_font)
.then_some(pdfboss_encoding::standard as fn(u8) -> Option<char>)
});
let diffs = differences(src, font).await;
let mut table = Box::new([0u16; 256]);
for (code, slot) in table.iter_mut().enumerate() {
let code = code as u8;
if let Some(name) = diffs.get(&code) {
if let Some(ch) = pdfboss_encoding::glyph_to_unicode(name) {
if let Some(gid) = tt.gid_for_unicode(ch as u32).filter(|&g| g != 0) {
*slot = gid;
continue;
}
}
}
if let Some(ch) = base.and_then(|f| f(code)) {
if let Some(gid) = tt.gid_for_unicode(ch as u32).filter(|&g| g != 0) {
*slot = gid;
}
}
}
let widths = simple_widths(src, font).await;
let mut afm_widths = FastMap::default();
if pdfboss_encoding::is_standard_14(base_font) {
let standard_ok = is_standard_encoding(src, font).await;
for code in 0u32..256 {
let name = diffs.get(&(code as u8)).map(String::as_str).or_else(|| {
standard_ok
.then(|| pdfboss_encoding::standard_encoding_name(code as u8))
.flatten()
});
if let Some(name) = name {
if let Some(w) = pdfboss_encoding::standard_14_width(base_font, name) {
afm_widths.insert(code, w);
}
}
}
}
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::Substitute(tt),
kind: GlyphKind::Simple(table),
widths,
afm_widths,
})
}
async fn load_type0<S: AsyncObjectSource>(
src: &S,
font: &Dict,
painting: GlyphPainting,
) -> Option<GlyphFont> {
let descendants = src.resolve(font.get("DescendantFonts")?).await.ok()?;
let first = descendants.as_array()?.first()?;
let cid = resolve_dict(src, first).await?;
match cid.get_name("Subtype").map(|n| n.0.as_str()) {
Some("CIDFontType2") => load_type0_truetype(src, &cid).await,
Some("CIDFontType0") if painting.paints_all_embedded() => load_cff_cid(src, &cid).await,
_ => None,
}
}
async fn load_type0_truetype<S: AsyncObjectSource>(src: &S, cid: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, cid.get("FontDescriptor")?).await?;
let program = stream_bytes(src, descriptor.get("FontFile2")?).await?;
let tt = TrueType::parse(program)?;
let map = match rv(src, cid, "CIDToGIDMap").await {
Some(Object::Stream(s)) => {
let bytes = src.stream_data(&s).await.ok()?;
Some(
bytes
.chunks_exact(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect(),
)
}
_ => None, };
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::TrueType(tt),
kind: GlyphKind::Cid(map),
widths: cid_widths(src, cid).await,
afm_widths: FastMap::default(),
})
}
async fn cid_widths<S: AsyncObjectSource>(src: &S, cid: &Dict) -> WidthMap {
let mut default = 1000.0;
let mut declared = false;
if let Some(dw) = rv(src, cid, "DW").await.and_then(|o| o.as_f64()) {
default = dw as f32;
declared = true;
}
let mut map = FastMap::default();
if let Some(Object::Array(items)) = rv(src, cid, "W").await {
declared = true;
parse_cid_width_array(src, &items, &mut map).await;
}
WidthMap {
map,
default,
declared,
}
}
const MAX_CID_WIDTH_ENTRIES: usize = 1_000_000;
async fn parse_cid_width_array<S: AsyncObjectSource>(
src: &S,
items: &[Object],
map: &mut FastMap<u32, f32>,
) {
let mut resolved: Vec<Object> = Vec::with_capacity(items.len());
for item in items {
resolved.push(src.resolve(item).await.unwrap_or(Object::Null));
}
let mut i = 0;
while i < resolved.len() {
if map.len() >= MAX_CID_WIDTH_ENTRIES {
break;
}
let Some(first) = resolved[i].as_int() else {
i += 1;
continue;
};
let first = first.max(0) as u32;
match resolved.get(i + 1) {
Some(Object::Array(list)) => {
for (j, item) in list.iter().enumerate() {
if map.len() >= MAX_CID_WIDTH_ENTRIES {
break;
}
let Some(code) = first.checked_add(j as u32) else {
break; };
if let Some(w) = src.resolve(item).await.ok().and_then(|o| o.as_f64()) {
map.insert(code, w as f32);
}
}
i += 2;
}
Some(other) if other.as_f64().is_some() => {
let last = other.as_int().unwrap_or(first as i64).max(0) as u32;
let w = resolved.get(i + 2).and_then(|o| o.as_f64());
if let Some(w) = w {
let end = last.min(first.saturating_add(65535));
for c in first..=end.max(first) {
if map.len() >= MAX_CID_WIDTH_ENTRIES {
break;
}
map.insert(c, w as f32);
}
}
i += 3;
}
_ => i += 1,
}
}
}
async fn load_cff_cid<S: AsyncObjectSource>(src: &S, cid: &Dict) -> Option<GlyphFont> {
let descriptor = resolve_dict(src, cid.get("FontDescriptor")?).await?;
let program = stream_bytes(src, descriptor.get("FontFile3")?).await?;
let cff = CffFont::parse(program)?;
let cid_to_gid = cff.cid_to_gid();
let widths = cid_widths(src, cid).await;
Some(GlyphFont {
outline_cache: Mutex::new(FastMap::default()),
flat_cache: Mutex::new(FastMap::default()),
outlines: Outlines::Cff(cff),
kind: GlyphKind::Cid(Some(cid_to_gid)),
widths,
afm_widths: FastMap::default(),
})
}
async fn resolve_dict<S: AsyncObjectSource>(src: &S, obj: &Object) -> Option<Dict> {
src.resolve(obj).await.ok()?.as_dict().cloned()
}
async fn stream_bytes<S: AsyncObjectSource>(src: &S, obj: &Object) -> Option<Vec<u8>> {
match src.resolve(obj).await.ok()? {
Object::Stream(s) => src.stream_data(&s).await.ok(),
_ => None,
}
}
#[cfg(test)]
mod tests {
use pdfboss_core::Document;
use pdfboss_testkit::PdfBuilder;
use crate::cff::tests::{build_box_glyph_fixture, build_box_glyph_fixture_cid};
use crate::truetype::tests::build_font;
use crate::type1::tests::{build_type1_box_fixture, build_type1_box_fixture_standard_encoding};
use crate::{GlyphPainting, Pixmap, RenderOptions, SubstituteSource};
#[test]
fn glyph_fonts_are_shareable_across_threads() {
use super::{FlatCache, GlyphFont};
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<GlyphFont>();
assert_send_sync::<std::sync::Arc<GlyphFont>>();
assert_send_sync::<FlatCache>();
}
#[test]
fn flatten_linear_then_translate_equals_full_transform() {
use super::{build_glyph, Seg};
use pdfboss_core::Matrix;
let segs = [
Seg::Move(0.0, 0.0),
Seg::Line(400.0, 0.0),
Seg::Quad(600.0, 500.0, 0.0, 700.0),
Seg::Close,
];
let (e, f) = (37.5, -12.25);
let linear = Matrix {
a: 0.02,
b: 0.0,
c: 0.0,
d: -0.02,
e: 0.0,
f: 0.0,
};
let full = Matrix { e, f, ..linear };
let rel = build_glyph(&segs, linear);
let full_polys = build_glyph(&segs, full);
assert_eq!(rel.len(), full_polys.len());
for (r, g) in rel.iter().zip(&full_polys) {
assert_eq!(r.points.len(), g.points.len(), "subdivision must match");
for (rp, gp) in r.points.iter().zip(&g.points) {
assert_eq!(rp.x + e, gp.x);
assert_eq!(rp.y + f, gp.y);
}
}
}
#[test]
fn flat_cache_is_bounded_under_transform_flooding() {
use super::{GlyphFont, MAX_FLAT_CACHE};
use pdfboss_core::{Matrix, ObjRef};
let bytes = simple_font_doc("/Encoding /WinAnsiEncoding", b"BT /F0 10 Tf (A) Tj ET");
let doc = Document::load(bytes).unwrap();
let font_obj = doc.get(ObjRef { num: 5, gen: 0 }).unwrap();
let font = font_obj.as_dict().unwrap();
let gf = GlyphFont::load(&doc, font, GlyphPainting::AllEmbedded, None).unwrap();
let gid = gf.gid(u32::from(b'A'));
assert_ne!(gid, 0, "fixture 'A' must map to a real glyph");
for i in 0..(MAX_FLAT_CACHE + 500) {
let s = 0.01 + i as f32 * 1e-6;
let linear = Matrix {
a: s,
b: 0.0,
c: 0.0,
d: -s,
e: 0.0,
f: 0.0,
};
assert!(
!gf.flattened(gid, linear).is_empty(),
"each glyph must still flatten to output past the cap"
);
}
assert!(
super::lock_cache(&gf.flat_cache).len() <= MAX_FLAT_CACHE,
"flat_cache must not grow past MAX_FLAT_CACHE"
);
}
fn simple_font_doc(encoding: &str, content: &[u8]) -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", content);
b.object(
5,
&format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /X \
/FontDescriptor 6 0 R {encoding} >>"
),
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile2 7 0 R >>",
);
b.stream(7, "", &build_font());
b.build(1)
}
fn dark_pixel_at(pix: &Pixmap, x: u32, y: u32) -> bool {
let o = ((y * pix.width + x) * 4) as usize;
pix.data[o] < 128 && pix.data[o + 1] < 128 && pix.data[o + 2] < 128
}
fn glyph_painted(bytes: Vec<u8>) -> bool {
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
dark_pixel_at(&pix, 55, 115)
}
#[test]
fn differences_name_paints_via_post() {
let doc = simple_font_doc(
"/Encoding << /Differences [128 /foo] >>",
b"BT /F0 100 Tf 20 50 Td <80> Tj ET",
);
assert!(
glyph_painted(doc),
"glyph should paint via /Differences+post"
);
}
#[test]
fn base_encoding_letter_still_paints() {
let doc = simple_font_doc(
"/Encoding /WinAnsiEncoding",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
assert!(
glyph_painted(doc),
"letter A should paint via the base encoding"
);
}
#[test]
fn differences_with_huge_code_does_not_panic() {
let doc = simple_font_doc(
"/Encoding << /Differences [9223372036854775807 /foo] >>",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
assert!(
glyph_painted(doc),
"render must complete without overflow panic"
);
}
fn simple_cff_font_doc(encoding: &str, content: &[u8]) -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", content);
b.object(
5,
&format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /X \
/FontDescriptor 6 0 R {encoding} >>"
),
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile3 7 0 R >>",
);
b.stream(7, "", &build_box_glyph_fixture("theboxglyphname"));
b.build(1)
}
fn render_at_tier(bytes: &[u8], tier: GlyphPainting) -> Pixmap {
let doc = Document::load(bytes.to_vec()).expect("load");
let page = doc.page(0).expect("page");
let opts = RenderOptions {
glyph_painting: tier,
..Default::default()
};
crate::render_page_with_options(&doc, &page, 1.0, &opts).expect("render")
}
#[test]
fn cff_simple_font_paints_at_all_embedded_and_full_not_embedded_truetype_only() {
let bytes = simple_cff_font_doc(
"/Encoding << /Differences [128 /theboxglyphname] >>",
b"BT /F0 100 Tf 20 50 Td <80> Tj ET",
);
for tier in [GlyphPainting::AllEmbedded, GlyphPainting::Full] {
let pix = render_at_tier(&bytes, tier);
assert!(
dark_pixel_at(&pix, 55, 115),
"embedded CFF glyph should paint at tier {tier:?}"
);
}
let pix = render_at_tier(&bytes, GlyphPainting::EmbeddedTrueTypeOnly);
assert!(
!dark_pixel_at(&pix, 55, 115),
"embedded CFF must not paint at EmbeddedTrueTypeOnly (tier gate)"
);
}
fn cid_cff_font_doc() -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <0005> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H \
/DescendantFonts [6 0 R] >>",
);
b.object(
6,
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /X \
/FontDescriptor 7 0 R >>",
);
b.object(
7,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile3 8 0 R >>",
);
b.stream(8, "", &build_box_glyph_fixture_cid(5));
b.build(1)
}
#[test]
fn cff_cid_font_paints_at_all_embedded_not_embedded_truetype_only() {
let bytes = cid_cff_font_doc();
let pix = render_at_tier(&bytes, GlyphPainting::AllEmbedded);
assert!(
dark_pixel_at(&pix, 55, 115),
"embedded CIDFontType0 (CFF) glyph should paint at AllEmbedded"
);
let pix = render_at_tier(&bytes, GlyphPainting::EmbeddedTrueTypeOnly);
assert!(
!dark_pixel_at(&pix, 55, 115),
"embedded CIDFontType0 (CFF) must not paint at EmbeddedTrueTypeOnly (tier gate)"
);
}
#[test]
fn simple_truetype_widths_advance_governs_second_glyph_origin() {
let bytes = simple_font_doc(
"/Encoding /WinAnsiEncoding /FirstChar 65 /Widths [800]",
b"BT /F0 100 Tf 20 50 Td <4141> Tj ET",
);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
assert!(
dark_pixel_at(&pix, 55, 115),
"first glyph paints at the usual (55,115)"
);
assert!(
dark_pixel_at(&pix, 135, 115),
"second glyph must paint at the /Widths-implied origin (135,115), \
not stacked on the first glyph as the program's 0 advance would give"
);
}
#[test]
fn simple_cff_widths_advance_governs_second_glyph_origin() {
let bytes = simple_cff_font_doc(
"/Encoding << /Differences [128 /theboxglyphname] >> \
/FirstChar 128 /Widths [800]",
b"BT /F0 100 Tf 20 50 Td <8080> Tj ET",
);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
assert!(
dark_pixel_at(&pix, 55, 115),
"first glyph paints at the usual (55,115)"
);
assert!(
dark_pixel_at(&pix, 135, 115),
"second glyph must paint at the /Widths-implied origin (135,115), \
not stacked on the first glyph as the program's 0 advance would give"
);
}
#[test]
fn type0_truetype_w_dw_advance_governs_second_glyph_origin() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <00010001> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H \
/DescendantFonts [6 0 R] >>",
);
b.object(
6,
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X \
/FontDescriptor 7 0 R /DW 1000 /W [1 [800]] >>",
);
b.object(
7,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile2 8 0 R >>",
);
b.stream(8, "", &build_font());
let bytes = b.build(1);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
assert!(
dark_pixel_at(&pix, 55, 115),
"first glyph paints at the usual (55,115)"
);
assert!(
dark_pixel_at(&pix, 135, 115),
"second glyph must paint at the /W-implied origin (135,115), not \
stacked on the first glyph as the program's 0 advance would give"
);
}
#[test]
fn cid_width_array_many_ranges_capped_does_not_oom() {
let mut w_array = String::new();
for k in 0u32..16 {
let start = k * 65536;
let end = start + 65535;
let width = if k == 0 { 800 } else { 500 };
w_array.push_str(&format!("{start} {end} {width} "));
}
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <00010001> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H \
/DescendantFonts [6 0 R] >>",
);
b.object(
6,
&format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X \
/FontDescriptor 7 0 R /DW 1000 /W [{w_array}] >>"
),
);
b.object(
7,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile2 8 0 R >>",
);
b.stream(8, "", &build_font());
let bytes = b.build(1);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let started = std::time::Instant::now();
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"capped /W parse must complete quickly, not hang expanding \
~1M+ range entries unbounded"
);
assert!(
dark_pixel_at(&pix, 55, 115),
"first glyph paints at the usual (55,115)"
);
assert!(
dark_pixel_at(&pix, 135, 115),
"CID 1's width (800, declared by the first range, well within \
the cap) must still govern the second glyph's origin -- the cap \
degrades the tail of a hostile array, not the CIDs that fit"
);
}
fn simple_type1_font_doc(encoding: &str, content: &[u8]) -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", content);
b.object(
5,
&format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /X \
/FontDescriptor 6 0 R {encoding} >>"
),
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile 7 0 R >>",
);
b.stream(7, "", &build_type1_box_fixture("theboxglyphname"));
b.build(1)
}
#[test]
fn type1_simple_font_paints_at_all_embedded_not_embedded_truetype_only() {
let bytes = simple_type1_font_doc(
"/Encoding << /Differences [128 /theboxglyphname] >>",
b"BT /F0 100 Tf 20 50 Td <80> Tj ET",
);
for tier in [GlyphPainting::AllEmbedded, GlyphPainting::Full] {
let pix = render_at_tier(&bytes, tier);
assert!(
dark_pixel_at(&pix, 55, 115),
"embedded Type1 glyph should paint at tier {tier:?}"
);
}
let pix = render_at_tier(&bytes, GlyphPainting::EmbeddedTrueTypeOnly);
assert!(
!dark_pixel_at(&pix, 55, 115),
"embedded Type1 must not paint at EmbeddedTrueTypeOnly (tier gate)"
);
}
#[test]
fn type1_builtin_encoding_paints_without_pdf_encoding() {
let bytes = simple_type1_font_doc("", b"BT /F0 100 Tf 20 50 Td <80> Tj ET");
let pix = render_at_tier(&bytes, GlyphPainting::AllEmbedded);
assert!(
dark_pixel_at(&pix, 55, 115),
"Type1 built-in /Encoding should map code 128 with no PDF /Encoding"
);
}
#[test]
fn type1_builtin_standard_encoding_token_paints_without_pdf_encoding() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <41> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /X /FontDescriptor 6 0 R >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile 7 0 R >>",
);
b.stream(7, "", &build_type1_box_fixture_standard_encoding());
let bytes = b.build(1);
let pix = render_at_tier(&bytes, GlyphPainting::AllEmbedded);
assert!(
dark_pixel_at(&pix, 55, 115),
"built-in StandardEncoding token should map code 65 ('A') with no \
PDF /Encoding at all"
);
}
#[test]
fn type1_widths_advance_governs_second_glyph_origin() {
let bytes = simple_type1_font_doc(
"/Encoding << /Differences [128 /theboxglyphname] >> \
/FirstChar 128 /Widths [800]",
b"BT /F0 100 Tf 20 50 Td <8080> Tj ET",
);
let doc = Document::load(bytes).expect("load");
let page = doc.page(0).expect("page");
let pix = crate::render_page(&doc, &page, 1.0).expect("render");
assert!(dark_pixel_at(&pix, 55, 115), "first glyph at (55,115)");
assert!(
dark_pixel_at(&pix, 135, 115),
"second glyph at the /Widths-implied (135,115)"
);
}
#[test]
fn type1_malformed_fontfile_degrades_to_blank() {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", b"BT /F0 100 Tf 20 50 Td <80> Tj ET");
b.object(
5,
"<< /Type /Font /Subtype /Type1 /BaseFont /X /FontDescriptor 6 0 R \
/Encoding << /Differences [128 /theboxglyphname] >> >>",
);
b.object(
6,
"<< /Type /FontDescriptor /FontName /X /Flags 4 /FontFile 7 0 R >>",
);
b.stream(7, "", b"not a real type1 program");
let bytes = b.build(1);
let pix = render_at_tier(&bytes, GlyphPainting::AllEmbedded);
assert!(
!dark_pixel_at(&pix, 55, 115),
"malformed FontFile paints nothing, no panic"
);
}
fn non_embedded_font_doc(base: &str, encoding: &str, content: &[u8]) -> Vec<u8> {
let mut b = PdfBuilder::new().version(1, 5);
b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
/Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
);
b.stream(4, "", content);
b.object(
5,
&format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} {encoding} >>"),
);
b.build(1)
}
fn write_temp_face(basename: &str, bytes: &[u8]) -> std::path::PathBuf {
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"pdfboss-glyph-substitute-test-{}-{}",
std::process::id(),
n
));
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join(basename), bytes).expect("write fixture face");
dir
}
fn render_with(bytes: &[u8], opts: RenderOptions) -> Pixmap {
let doc = Document::load(bytes.to_vec()).expect("load");
let page = doc.page(0).expect("page");
crate::render_page_with_options(&doc, &page, 1.0, &opts).expect("render")
}
#[test]
fn non_embedded_helvetica_paints_at_full_via_substitute() {
let bytes = non_embedded_font_doc(
"Helvetica",
"/Encoding /WinAnsiEncoding",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
let dir = write_temp_face("Arimo[wght].ttf", &build_font());
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
},
);
assert!(
dark_pixel_at(&pix, 55, 115),
"non-embedded Helvetica should paint via Full-tier substitution"
);
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::None,
},
);
assert!(
!dark_pixel_at(&pix, 55, 115),
"Full with SubstituteSource::None must not paint (no provider)"
);
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::AllEmbedded,
substitutes: SubstituteSource::Dir(dir.clone()),
},
);
assert!(
!dark_pixel_at(&pix, 55, 115),
"AllEmbedded must not substitute even with a provider present (tier gate)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn non_embedded_helvetica_uses_afm_width_when_widths_absent() {
let bytes = non_embedded_font_doc(
"Helvetica",
"/Encoding << /Differences [128 /space] >>",
b"BT /F0 100 Tf 20 50 Td <8041> Tj ET",
);
let dir = write_temp_face("Arimo[wght].ttf", &build_font());
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
},
);
assert!(
!dark_pixel_at(&pix, 55, 115),
"the /space code itself should paint nothing"
);
assert!(
dark_pixel_at(&pix, 83, 115),
"'A' should land at the AFM-implied origin (20 + 27.8 + 35 ~= 83), \
not stacked on the first glyph (0 advance) or elsewhere"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn non_embedded_helvetica_widths_still_wins_over_afm() {
let bytes = non_embedded_font_doc(
"Helvetica",
"/Encoding << /Differences [128 /space] >> /FirstChar 128 /Widths [800]",
b"BT /F0 100 Tf 20 50 Td <8041> Tj ET",
);
let dir = write_temp_face("Arimo[wght].ttf", &build_font());
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
},
);
assert!(
!dark_pixel_at(&pix, 83, 115),
"the AFM-implied origin must lose to /Widths"
);
assert!(
dark_pixel_at(&pix, 135, 115),
"'A' should land at the /Widths-implied origin (20 + 80 + 35 = 135)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn non_embedded_helvetica_bare_encoding_paints_via_standard_default() {
let bytes = non_embedded_font_doc("Helvetica", "", b"BT /F0 100 Tf 20 50 Td <41> Tj ET");
let dir = write_temp_face("Arimo[wght].ttf", &build_font());
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Dir(dir.clone()),
},
);
assert!(
dark_pixel_at(&pix, 55, 115),
"bare /Type1 /Helvetica with no /Encoding key at all should still \
paint via the standard-14 implicit StandardEncoding default"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "substitute-fonts")]
fn has_dark_ink(pix: &Pixmap) -> bool {
pix.data
.chunks_exact(4)
.any(|p| p[0] < 200 && p[1] < 200 && p[2] < 200)
}
#[cfg(feature = "substitute-fonts")]
#[test]
fn non_embedded_times_paints_with_builtin_at_full() {
let bytes = non_embedded_font_doc(
"Times-Roman",
"/Encoding /WinAnsiEncoding",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Builtin,
},
);
assert!(
has_dark_ink(&pix),
"non-embedded Times-Roman should paint a real 'A' via the builtin Tinos substitute"
);
}
#[cfg(feature = "substitute-fonts")]
#[test]
fn symbol_stays_unpainted_at_full() {
let bytes = non_embedded_font_doc(
"Symbol",
"/Encoding /WinAnsiEncoding",
b"BT /F0 100 Tf 20 50 Td <41> Tj ET",
);
let pix = render_with(
&bytes,
RenderOptions {
glyph_painting: GlyphPainting::Full,
substitutes: SubstituteSource::Builtin,
},
);
assert!(
!has_dark_ink(&pix),
"Symbol has no v1 substitute; Full must leave it unpainted, not fall back to a wrong face"
);
}
}