#![allow(clippy::collapsible_if, clippy::vec_box)]
use super::{create_fill_paint, guarded_fill_path};
use crate::content::GraphicsState;
use crate::content::operators::TextElement;
use crate::document::PdfDocument;
use crate::error::{Error, Result};
use crate::fonts::unicode_decode::{
ByteMode, DecodePolicy, GlyphDropTally, TextCharIter, char_codes, char_codes_with_len, get_byte_mode,
};
use crate::object::Object;
use crate::vendor::fontdb;
use std::collections::HashMap;
use std::sync::Arc;
use skrifa::instance::{LocationRef, Size};
use skrifa::metrics::GlyphMetrics;
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::TableProvider;
use skrifa::raw::tables::cmap::PlatformId;
use skrifa::{FontRef, GlyphId, MetadataProvider};
use tiny_skia::{Paint, PathBuilder, Pixmap, Transform};
struct SkiaOutlineBuilder<'a>(&'a mut PathBuilder);
impl OutlinePen for SkiaOutlineBuilder<'_> {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to(x, y);
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to(x, y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
self.0.quad_to(x1, y1, x, y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
self.0.cubic_to(x1, y1, x2, y2, x, y);
}
fn close(&mut self) {
self.0.close();
}
}
fn classify_embedded_font(data: &Arc<Vec<u8>>) -> (bool, bool) {
(|| {
let font = FontRef::new(data).ok()?;
let cmap = font.cmap().ok()?;
let mut saw_byte_indexed = false;
let mut saw_unicode = false;
for record in cmap.encoding_records() {
match record.platform_id() {
PlatformId::Unicode => saw_unicode = true,
PlatformId::Windows if record.encoding_id() == 1 || record.encoding_id() == 10 => {
saw_unicode = true;
}
PlatformId::Macintosh if record.encoding_id() == 0 => saw_byte_indexed = true,
_ => {}
}
}
Some((saw_byte_indexed && !saw_unicode, saw_unicode))
})()
.unwrap_or((false, false))
}
fn outlineable_font_bytes(data: &[u8], index: u32) -> std::borrow::Cow<'_, [u8]> {
let Ok(font) = FontRef::from_index(data, index) else {
return std::borrow::Cow::Borrowed(data);
};
if font.outline_glyphs().format().is_some() {
return std::borrow::Cow::Borrowed(data);
}
crate::fonts::cmap_injector::inject_hmtx(data, &std::collections::HashMap::new())
.map(std::borrow::Cow::Owned)
.unwrap_or(std::borrow::Cow::Borrowed(data))
}
struct OutlineFace<'a> {
font: FontRef<'a>,
outlines: skrifa::outline::OutlineGlyphCollection<'a>,
charmap: skrifa::charmap::Charmap<'a>,
glyph_metrics: GlyphMetrics<'a>,
units_per_em: f32,
}
impl<'a> OutlineFace<'a> {
fn new(data: &'a [u8], index: u32) -> Option<Self> {
let font = FontRef::from_index(data, index).ok()?;
Some(Self::from_font(font))
}
fn from_font(font: FontRef<'a>) -> Self {
let units_per_em = font.head().map(|head| head.units_per_em()).unwrap_or(1000) as f32;
Self {
outlines: font.outline_glyphs(),
charmap: font.charmap(),
glyph_metrics: GlyphMetrics::new(&font, Size::unscaled(), LocationRef::default()),
units_per_em,
font,
}
}
fn units_per_em(&self) -> f32 {
self.units_per_em
}
fn glyph_index(&self, ch: char) -> Option<GlyphId> {
self.charmap.map(ch)
}
fn glyph_hor_advance(&self, glyph: GlyphId) -> Option<f32> {
self.glyph_metrics.advance_width(glyph)
}
fn outline_glyph(&self, glyph: GlyphId, pen: &mut impl OutlinePen) -> Option<()> {
let outline = self.outlines.get(glyph)?;
let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default());
outline.draw(settings, pen).ok().map(|_| ())
}
fn cmap_byte_to_gid(&self, byte: u8) -> Option<u16> {
if let Ok(cmap) = self.font.cmap() {
for record in cmap.encoding_records() {
if record.platform_id() == PlatformId::Macintosh
&& record.encoding_id() == 0
&& let Ok(subtable) = record.subtable(cmap.offset_data())
&& let Some(gid) = subtable.map_codepoint(byte as u32)
{
return Some(gid.to_u32() as u16);
}
}
}
self.glyph_index(byte as char).map(|g| g.to_u32() as u16)
}
}
static SYSTEM_FONTDB: std::sync::OnceLock<std::sync::Arc<fontdb::Database>> = std::sync::OnceLock::new();
fn system_fontdb() -> std::sync::Arc<fontdb::Database> {
SYSTEM_FONTDB
.get_or_init(|| {
let mut db = fontdb::Database::new();
db.load_system_fonts();
#[cfg(feature = "cjk-render-fallback")]
db.load_font_data(
crate::fonts::form_fallback::font_bytes(crate::fonts::form_fallback::Fallback::Cjk).to_vec(),
);
std::sync::Arc::new(db)
})
.clone()
}
static FONT_BYTES_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<fontdb::ID, (Arc<Vec<u8>>, u32)>>,
> = std::sync::OnceLock::new();
fn cached_font_bytes(id: fontdb::ID, db: &fontdb::Database) -> Option<(Arc<Vec<u8>>, u32)> {
let cache = FONT_BYTES_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
{
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get(&id) {
return Some(entry.clone());
}
}
let mut result: Option<(Arc<Vec<u8>>, u32)> = None;
db.with_face_data(id, |data, index| {
result = Some((Arc::new(data.to_vec()), index));
});
if let Some(ref entry) = result {
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
guard.insert(id, entry.clone());
}
result
}
struct CachedFace {
data: Arc<Vec<u8>>,
index: u32,
shaper_data: harfrust::ShaperData,
pub units_per_em: f32,
}
impl CachedFace {
fn new(data: Arc<Vec<u8>>, index: u32) -> Option<Self> {
let font = harfrust::FontRef::from_index(&data, index).ok()?;
let units_per_em = font.head().map(|head| head.units_per_em()).unwrap_or(1000) as f32;
let shaper_data = harfrust::ShaperData::new(&font);
Some(CachedFace {
data,
index,
shaper_data,
units_per_em,
})
}
fn font(&self) -> harfrust::FontRef<'_> {
harfrust::FontRef::from_index(&self.data, self.index)
.expect("font parsed successfully when the cache entry was built")
}
fn outline_face(&self) -> OutlineFace<'_> {
OutlineFace::from_font(self.font())
}
}
static FACE_CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<(fontdb::ID, u32), Arc<CachedFace>>>,
> = std::sync::OnceLock::new();
fn cached_face(id: fontdb::ID, data: Arc<Vec<u8>>, index: u32) -> Option<Arc<CachedFace>> {
let cache = FACE_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
{
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.get(&(id, index)) {
return Some(entry.clone());
}
}
let face = CachedFace::new(data, index)?;
let arc = Arc::new(face);
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
guard.insert((id, index), arc.clone());
Some(arc)
}
static CJK_FALLBACK: std::sync::OnceLock<Option<(fontdb::ID, Arc<Vec<u8>>, u32)>> = std::sync::OnceLock::new();
fn get_cjk_fallback_cached(db: &fontdb::Database) -> Option<(fontdb::ID, Arc<Vec<u8>>, u32)> {
CJK_FALLBACK
.get_or_init(|| {
let prioritized_variants = [
"Noto Sans CJK SC",
"Noto Serif CJK SC",
"Droid Sans Fallback",
"SimSun",
"WenQuanYi Micro Hei",
"Noto Sans CJK JP",
"Noto Serif CJK JP",
];
for variant in prioritized_variants {
let query = fontdb::Query {
families: &[fontdb::Family::Name(variant)],
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
};
if let Some(id) = db.query(&query) {
if let Some((arc, idx)) = cached_font_bytes(id, db) {
tracing::debug!(
"CJK fallback: matched '{}', idx={}, size={} bytes",
variant,
idx,
arc.len()
);
return Some((id, arc, idx));
}
}
}
let query = fontdb::Query {
families: &[fontdb::Family::SansSerif],
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
};
if let Some(id) = db.query(&query) {
if let Some((arc, idx)) = cached_font_bytes(id, db) {
return Some((id, arc, idx));
}
}
None
})
.as_ref()
.map(|(id, arc, idx)| (*id, Arc::clone(arc), *idx))
}
#[cfg(feature = "cjk-render-fallback")]
static RENDER_CJK_FALLBACK_FACE: std::sync::OnceLock<Option<Arc<CachedFace>>> = std::sync::OnceLock::new();
#[cfg(feature = "cjk-render-fallback")]
fn render_cjk_fallback_face() -> Option<Arc<CachedFace>> {
RENDER_CJK_FALLBACK_FACE
.get_or_init(|| {
let bytes: &'static [u8] = crate::fonts::form_fallback::render_cjk_fallback_bytes();
CachedFace::new(Arc::new(bytes.to_vec()), 0).map(Arc::new)
})
.clone()
}
pub struct TextRasterizer {
fontdb: std::sync::Arc<fontdb::Database>,
warned_fonts: std::sync::Mutex<std::collections::HashSet<(String, &'static str)>>,
}
impl TextRasterizer {
pub fn new() -> Self {
Self {
fontdb: system_fontdb(),
warned_fonts: Default::default(),
}
}
#[allow(dead_code)]
pub fn with_fontdb(fontdb: std::sync::Arc<fontdb::Database>) -> Self {
Self {
fontdb,
warned_fonts: Default::default(),
}
}
pub(crate) fn reset_page_warnings(&self) {
if let Ok(mut warned) = self.warned_fonts.lock() {
warned.clear();
}
}
fn first_report_for(&self, font_name: &str, reason: &'static str) -> bool {
match self.warned_fonts.lock() {
Ok(mut warned) => warned.insert((font_name.to_string(), reason)),
Err(_) => false,
}
}
fn report_drops(&self, tally: &GlyphDropTally, font_name: &str) {
self.emit_drop_warning(tally, font_name, tally.warning("redacted"));
}
fn report_omitted_drops(&self, tally: &GlyphDropTally, font_name: &str) {
self.emit_drop_warning(tally, font_name, tally.warning_omitted("redacted"));
}
fn emit_drop_warning(
&self,
tally: &GlyphDropTally,
font_name: &str,
warning: Option<crate::extractors::warnings::Warning>,
) {
let (Some(warning), Some(reason)) = (warning, tally.reason()) else {
return;
};
if !self.first_report_for(font_name, reason) {
return;
}
tracing::warn!(
target: "xberg_native_pdf::fonts",
operation = "render_glyph",
error_code = "glyph_dropped",
message = warning.message,
"glyph rendering omitted content"
);
crate::extractors::warnings::push_global_warning(warning);
}
fn warn_font_not_found(_pdf_font_name: &str) {
tracing::warn!(
target: "xberg_native_pdf::fonts",
operation = "resolve_font",
error_code = "font_not_found",
"no usable PDF font was found; text may render incorrectly; install common system fonts"
);
}
fn warn_invalid_embedded_font(_pdf_font_name: &str) {
tracing::warn!(
target: "xberg_native_pdf::fonts",
operation = "parse_embedded_font",
error_code = "invalid_font_data",
"embedded PDF font data could not be parsed; falling back to a system font"
);
}
#[cfg(any(feature = "cjk-render-fallback", test))]
fn warn_cjk_fallback_unavailable(_pdf_font_name: &str) {
tracing::warn!(
target: "xberg_native_pdf::fonts",
operation = "load_cjk_fallback",
error_code = "font_unavailable",
"CJK predefined-CIDFont substitution is unavailable; falling back to advance-only rendering"
);
}
#[allow(unused_variables)]
pub fn render_text(
&self,
pixmap: &mut Pixmap,
text: &[u8],
base_transform: Transform,
gs: &GraphicsState,
color_override: Option<&crate::rendering::page_renderer::ResolvedColors>,
_resources: &Object,
doc: &PdfDocument,
clip_mask: Option<&tiny_skia::Mask>,
font_cache: &HashMap<String, Arc<crate::fonts::FontInfo>>,
) -> Result<f32> {
let font_info = if let Some(font_name) = &gs.font_name {
font_cache.get(font_name).cloned()
} else {
None
};
let (unicode_text, decode_drops) = self.decode_text_to_unicode(text, font_info.as_deref());
tracing::trace!("Decoded PDF text for rendering");
let mut paint = create_fill_paint(gs, "Normal");
if let Some(overrides) = color_override {
if let Some((r, g, b, a)) = overrides.fill {
paint.set_color(tiny_skia::Color::from_rgba(r, g, b, a).unwrap_or(tiny_skia::Color::BLACK));
}
}
if gs.render_mode == 3 || gs.render_mode == 7 {
paint.set_color(tiny_skia::Color::from_rgba(0.0, 0.0, 0.0, 0.0).unwrap());
}
#[cfg(feature = "cjk-render-fallback")]
if let Some(ref info) = font_info {
if let Some(collection) = info.cjk_substitution {
tracing::trace!(
"Routing PDF font through CJK substitution path (collection {:?})",
collection
);
return self.render_substituted_cjk(
pixmap,
text,
info,
collection,
&paint,
base_transform,
gs,
clip_mask,
);
}
}
let pdf_font_name = gs.font_name.as_deref().unwrap_or("Helvetica");
let font_data_and_index: Option<(Option<fontdb::ID>, Arc<Vec<u8>>, u32, bool)> =
if let Some(ref info) = font_info {
if let Some(ref embedded) = info.embedded_font_data {
let (is_byte_indexed, has_unicode_cmap) = classify_embedded_font(embedded);
if info.subtype != "Type0" && is_byte_indexed {
tracing::trace!("Using embedded PDF font with byte-indexed cmap (simple TrueType subset)");
return self.render_cid_direct(
pixmap,
text,
info,
embedded,
0,
&paint,
base_transform,
gs,
clip_mask,
);
}
if has_unicode_cmap {
tracing::trace!("Using embedded PDF font data");
Some((None, Arc::clone(embedded), 0, false))
} else if info.subtype == "Type0"
&& info.cid_to_gid_map.is_some()
&& info.cid_font_type.as_deref() == Some("CIDFontType2")
{
tracing::trace!("Using embedded PDF font with CIDToGIDMap (CIDFontType2)");
Some((None, Arc::clone(embedded), 0, true))
} else if info.cff_gid_map.is_some()
|| (info.subtype == "Type0" && info.cid_font_type.as_deref() == Some("CIDFontType0"))
{
tracing::trace!("Using embedded CFF PDF font with direct GID mapping");
Some((None, Arc::clone(embedded), 0, true))
} else {
tracing::trace!("Embedded PDF font lacks usable cmap, falling back to system font");
self.load_font_data(&info.base_font)
.map(|(id, d, i)| (Some(id), d, i, false))
}
} else {
self.load_font_data(&info.base_font)
.map(|(id, d, i)| (Some(id), d, i, false))
}
} else {
self.load_font_data(pdf_font_name)
.map(|(id, d, i)| (Some(id), d, i, false))
};
if let Some((font_id, font_data, index, use_cid_to_gid)) = font_data_and_index {
if use_cid_to_gid {
match self.render_cid_direct(
pixmap,
text,
font_info.as_deref().unwrap(),
&font_data,
index,
&paint,
base_transform,
gs,
clip_mask,
) {
Ok(advance) => return Ok(advance),
Err(error) => {
tracing::warn!(
error_code = error.telemetry_code(),
error_offset = ?error.telemetry_offset(),
"direct CID/CFF rendering failed; falling back to system font"
);
if let Some((fb_id, fallback_data, fallback_idx)) = self.load_font_data(pdf_font_name) {
if let Some(ref info) = font_info {
self.report_omitted_drops(&decode_drops, &info.base_font);
}
return self.render_unicode_text(
pixmap,
&unicode_text,
text,
font_info.as_deref(),
Some(fb_id),
fallback_data,
fallback_idx,
&paint,
base_transform,
gs,
clip_mask,
pdf_font_name,
false,
);
}
}
}
}
if let Some(ref info) = font_info {
self.report_omitted_drops(&decode_drops, &info.base_font);
}
Ok(self.render_unicode_text(
pixmap,
&unicode_text,
text,
font_info.as_deref(),
font_id,
font_data,
index,
&paint,
base_transform,
gs,
clip_mask,
pdf_font_name,
true,
)?)
} else {
let font_name = font_info.as_ref().map(|i| i.base_font.as_str()).unwrap_or("unknown");
Self::warn_font_not_found(font_name);
if let Some(ref info) = font_info {
self.report_omitted_drops(&decode_drops, &info.base_font);
}
Ok(self.render_text_fallback(pixmap, &unicode_text, &paint, base_transform, gs, clip_mask)?)
}
}
fn decode_text_to_unicode(&self, bytes: &[u8], font: Option<&crate::fonts::FontInfo>) -> (String, GlyphDropTally) {
let mut undecodable = GlyphDropTally::default();
let result = crate::fonts::unicode_decode::decode_text_to_unicode(
bytes,
font,
DecodePolicy {
preserve_unmapped: false,
decompose_ligatures: true,
question_mark_for_invalid: false,
},
Some(&mut undecodable),
);
(result, undecodable)
}
pub fn measure_text(
&self,
text: &[u8],
gs: &GraphicsState,
font_cache: &HashMap<String, Arc<crate::fonts::FontInfo>>,
) -> f32 {
let font_info = gs.font_name.as_ref().and_then(|n| font_cache.get(n).cloned());
measure_text_bytes(text, gs, font_info.as_deref())
}
pub fn measure_tj_array(
&self,
array: &[TextElement],
gs: &GraphicsState,
font_cache: &HashMap<String, Arc<crate::fonts::FontInfo>>,
) -> f32 {
let font_info = gs.font_name.as_ref().and_then(|n| font_cache.get(n).cloned());
let mut total: f32 = 0.0;
for element in array {
match element {
TextElement::String(text) => {
total += measure_text_bytes(text, gs, font_info.as_deref());
}
TextElement::Offset(offset) => {
let shift = (-offset / 1000.0) * gs.font_size;
total += shift;
}
}
}
total
}
pub fn render_tj_array(
&self,
pixmap: &mut Pixmap,
array: &[TextElement],
base_transform: Transform,
gs: &GraphicsState,
color_override: Option<&crate::rendering::page_renderer::ResolvedColors>,
resources: &Object,
doc: &PdfDocument,
clip_mask: Option<&tiny_skia::Mask>,
font_cache: &HashMap<String, Arc<crate::fonts::FontInfo>>,
) -> Result<f32> {
let mut current_gs = gs.clone();
let mut total_advance: f32 = 0.0;
for element in array {
match element {
TextElement::String(text) => {
let advance = self.render_text(
pixmap,
text,
base_transform,
¤t_gs,
color_override,
resources,
doc,
clip_mask,
font_cache,
)?;
current_gs.advance_text_matrix(advance);
total_advance += advance;
}
TextElement::Offset(offset) => {
let shift = (-offset / 1000.0) * current_gs.font_size;
current_gs.advance_text_matrix(shift);
total_advance += shift;
}
}
}
Ok(total_advance)
}
fn load_font_data(&self, pdf_font_name: &str) -> Option<(fontdb::ID, Arc<Vec<u8>>, u32)> {
let clean_name = if let Some(plus_idx) = pdf_font_name.find('+') {
&pdf_font_name[plus_idx + 1..]
} else {
pdf_font_name
};
let is_cjk_probability = clean_name.contains("GB2312")
|| clean_name.contains("Identity")
|| clean_name.contains("楷体")
|| clean_name.contains("楷ä½")
|| clean_name.contains("宋体")
|| clean_name.contains("å®\u{008b}ä½")
|| clean_name.contains("黑体")
|| clean_name.contains("é»\u{0091}ä½")
|| clean_name.contains("FangSong")
|| clean_name.contains("SimSun")
|| clean_name.contains("SimHei")
|| clean_name.contains("KaiTi")
|| pdf_font_name == "F1";
let final_name = if clean_name.contains("楷体") || clean_name.contains("楷ä½") || clean_name.contains("KaiTi")
{
"KaiTi"
} else if clean_name.contains("宋体") || clean_name.contains("å®\u{008b}ä½") || clean_name.contains("SimSun")
{
"SimSun"
} else if clean_name.contains("黑体") || clean_name.contains("é»\u{0091}ä½") || clean_name.contains("SimHei")
{
"SimHei"
} else {
clean_name
};
let mut variants = vec![final_name.to_string()];
if clean_name.contains("URWPalladioL") || clean_name.contains("Palatino") {
variants.insert(0, "P052".to_string());
variants.push("Palatino Linotype".to_string());
variants.push("TeX Gyre Pagella".to_string());
} else if clean_name.contains("NimbusRomNo9L") || clean_name.contains("NimbusRoman") {
variants.insert(0, "Nimbus Roman".to_string());
variants.push("Times New Roman".to_string());
} else if clean_name.contains("NimbusSanL") || clean_name.contains("NimbusSans") {
variants.insert(0, "Nimbus Sans".to_string());
variants.push("Arial".to_string());
} else if clean_name.contains("NimbusMonL") || clean_name.contains("NimbusMono") {
variants.insert(0, "Nimbus Mono PS".to_string());
variants.push("Courier New".to_string());
} else if clean_name.contains("CMSS") || clean_name.contains("CMR") || clean_name.contains("CMBX") {
variants.push("Latin Modern Roman".to_string());
variants.push("Computer Modern".to_string());
} else if clean_name.contains("URWBookmanL") || clean_name.contains("Bookman") {
variants.insert(0, "Bookman URW".to_string());
} else if clean_name.contains("CenturySchL") || clean_name.contains("NewCentury") {
variants.insert(0, "C059".to_string());
} else if clean_name.contains("URWChanceryL") || clean_name.contains("Chancery") {
variants.insert(0, "Z003".to_string());
}
if is_cjk_probability {
variants.push("Noto Sans CJK SC".to_string());
variants.push("Noto Serif CJK SC".to_string());
variants.push("WenQuanYi Micro Hei".to_string());
variants.push("Droid Sans Fallback".to_string());
}
let is_serif = clean_name.contains("Roman")
|| clean_name.contains("Serif")
|| clean_name.contains("Times")
|| clean_name.contains("Palladio")
|| clean_name.contains("Palatino")
|| clean_name.contains("Bookman")
|| clean_name.contains("Garamond")
|| clean_name.contains("Century")
|| clean_name.contains("Georgia")
|| clean_name.contains("CMR")
|| clean_name.contains("CMBX")
|| clean_name.contains("CMTI");
if is_serif {
variants.push("Times New Roman".to_string());
variants.push("Liberation Serif".to_string());
variants.push("DejaVu Serif".to_string());
}
variants.push("Arial".to_string());
variants.push("Helvetica".to_string());
variants.push("Liberation Sans".to_string());
variants.push("DejaVu Sans".to_string());
variants.push("Noto Sans".to_string());
variants.push("FreeSans".to_string());
let weight = if pdf_font_name.contains("Bold") || pdf_font_name.contains("Black") {
fontdb::Weight::BOLD
} else {
fontdb::Weight::NORMAL
};
let style = if pdf_font_name.contains("Italic") || pdf_font_name.contains("Oblique") {
fontdb::Style::Italic
} else {
fontdb::Style::Normal
};
for variant in variants {
let families = [
fontdb::Family::Name(&variant),
fontdb::Family::Serif,
fontdb::Family::SansSerif,
];
let query = fontdb::Query {
families: &families,
weight,
stretch: fontdb::Stretch::Normal,
style,
};
if let Some(id) = self.font_db().query(&query) {
if let Some((arc_data, index)) = cached_font_bytes(id, self.font_db()) {
tracing::trace!("Matched system font: index={}, size={} bytes", index, arc_data.len());
return Some((id, arc_data, index));
}
}
}
tracing::trace!("No system font matched after trying all fallback variants");
None
}
fn font_db(&self) -> &fontdb::Database {
&self.fontdb
}
fn render_unicode_text(
&self,
pixmap: &mut Pixmap,
text: &str,
bytes: &[u8],
font_info: Option<&crate::fonts::FontInfo>,
font_id: Option<fontdb::ID>,
font_data: Arc<Vec<u8>>,
index: u32,
paint: &Paint,
base_transform: Transform,
gs: &GraphicsState,
clip_mask: Option<&tiny_skia::Mask>,
pdf_font_name: &str,
allow_fallback: bool,
) -> Result<f32> {
let font_size = gs.font_size;
let h_scale = gs.horizontal_scaling / 100.0;
let cached_arc: Option<Arc<CachedFace>> = font_id.and_then(|id| cached_face(id, Arc::clone(&font_data), index));
let _local_shaper_data: Option<harfrust::ShaperData>;
let local_outline_bytes: std::borrow::Cow<'_, [u8]>;
let font_ref: harfrust::FontRef<'_>;
let shaper_data_ref: &harfrust::ShaperData;
let outline_face: OutlineFace<'_>;
let units_per_em: f32;
if let Some(ref c) = cached_arc {
_local_shaper_data = None;
font_ref = c.font();
shaper_data_ref = &c.shaper_data;
outline_face = c.outline_face();
units_per_em = c.units_per_em;
} else {
let font_opt = harfrust::FontRef::from_index(&font_data, index).ok();
if font_opt.is_none() {
if allow_fallback {
Self::warn_invalid_embedded_font(pdf_font_name);
if let Some((fb_id, fallback_data, fallback_index)) = self.load_font_data(pdf_font_name) {
return self.render_unicode_text(
pixmap,
text,
bytes,
font_info,
Some(fb_id),
fallback_data,
fallback_index,
paint,
base_transform,
gs,
clip_mask,
pdf_font_name,
false,
);
}
}
return self.render_text_fallback(pixmap, text, paint, base_transform, gs, clip_mask);
}
font_ref = font_opt.unwrap();
local_outline_bytes = outlineable_font_bytes(&font_data, index);
let Some(face) = OutlineFace::new(&local_outline_bytes, index) else {
return Err(Error::InvalidPdf(format!("Failed to parse font: {}", pdf_font_name)));
};
units_per_em = face.units_per_em();
outline_face = face;
_local_shaper_data = Some(harfrust::ShaperData::new(&font_ref));
shaper_data_ref = _local_shaper_data.as_ref().unwrap();
}
let mut buffer = harfrust::UnicodeBuffer::new();
buffer.push_str(text);
if text.chars().any(|c| (c as u32) >= 0x4E00 && (c as u32) <= 0x9FFF) {
if let Some(script) = harfrust::Script::from_iso15924_tag(harfrust::Tag::new(b"Hani")) {
buffer.set_script(script);
}
}
buffer.set_direction(harfrust::Direction::LeftToRight);
buffer.guess_segment_properties();
let shaper = shaper_data_ref.shaper(&font_ref).instance(None).build();
let glyphs = shaper.shape(buffer, harfrust::ShapeOptions::new());
let info = glyphs.glyph_infos();
let pos = glyphs.glyph_positions();
let scale = font_size / units_per_em;
tracing::trace!(
"render_unicode_text: units_per_em={}, font_size={}, scale={}",
units_per_em,
font_size,
scale
);
let text_transform = Transform::from_row(
gs.text_matrix.a,
gs.text_matrix.b,
gs.text_matrix.c,
gs.text_matrix.d,
gs.text_matrix.e,
gs.text_matrix.f,
);
let combined_base = base_transform.pre_concat(text_transform);
let mut x_cursor: f32 = 0.0;
let mut y_cursor: f32 = 0.0;
let mut unicode_dropped = GlyphDropTally::default();
let mut last_fallback_cluster: Option<usize> = None;
let wmode = gs.text_wmode;
let word_space_eligible = get_byte_mode(font_info) != ByteMode::TwoByte;
let cids: Vec<u16> = if let Some(info) = font_info {
if info.subtype == "Type0" {
char_codes(bytes, info)
.into_iter()
.map(|code| info.code_to_cid(code))
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
let cluster_to_char_idx: HashMap<usize, usize> = text
.char_indices()
.enumerate()
.map(|(char_idx, (byte_offset, _))| (byte_offset, char_idx))
.collect();
for i in 0..info.len() {
let glyph_id = info[i].glyph_id;
let cluster = info[i].cluster as usize;
let char_at_pos = text[cluster..].chars().next().unwrap_or(' ');
let char_idx = cluster_to_char_idx.get(&cluster).copied().unwrap_or(0);
let next_cluster_byte: usize = info.get(i + 1).map(|n| n.cluster as usize).unwrap_or(text.len());
let cluster_chars: usize = text[cluster..next_cluster_byte.min(text.len())].chars().count().max(1);
let pdf_width = if let Some(font_info_ref) = font_info {
let mut sum = 0.0_f32;
for k in 0..cluster_chars {
let idx = char_idx + k;
let char_code = if font_info_ref.subtype == "Type0" {
*cids.get(idx).unwrap_or(&0)
} else {
*bytes.get(idx).unwrap_or(&0) as u16
};
sum += font_info_ref.get_glyph_width(char_code);
}
sum
} else {
pos[i].x_advance as f32 / font_size * 1000.0
};
let x_advance = pdf_width * font_size / 1000.0;
let x_offset = pos[i].x_offset as f32 / units_per_em * font_size;
let y_offset = pos[i].y_offset as f32 / units_per_em * font_size;
let mut x_advance_override: Option<f32> = None;
let (y_step, paint_origin_dx, paint_origin_dy) = if wmode == 1 {
if let Some(font_info_ref) = font_info {
let mut w1y_sum = 0.0_f32;
let mut head_v_x = 0.0_f32;
let mut head_v_y = 0.0_f32;
for k in 0..cluster_chars {
let idx = char_idx + k;
let cid = if font_info_ref.subtype == "Type0" {
*cids.get(idx).unwrap_or(&0)
} else {
*bytes.get(idx).unwrap_or(&0) as u16
};
let m = font_info_ref.get_vertical_metrics(cid);
w1y_sum += m.w1y;
if k == 0 {
head_v_x = m.v_x;
head_v_y = m.v_y;
}
}
let y_advance_v = w1y_sum * font_size / 1000.0;
let dx = -head_v_x * font_size / 1000.0;
let dy = -head_v_y * font_size / 1000.0;
(y_advance_v, dx, dy)
} else {
let m = crate::fonts::VerticalMetrics::SPEC_DEFAULT;
(
m.w1y * font_size / 1000.0,
-m.v_x * font_size / 1000.0,
-m.v_y * font_size / 1000.0,
)
}
} else {
(0.0, 0.0, 0.0)
};
let mut pb = PathBuilder::new();
let mut builder = SkiaOutlineBuilder(&mut pb);
let mut has_outline = outline_face
.outline_glyph(GlyphId::from(glyph_id as u16), &mut builder)
.is_some();
if has_outline && glyph_id != 0 {
if let Some(path) = pb.finish() {
let (rise_x, rise_y) = if wmode == 0 {
(0.0, gs.text_rise)
} else {
(gs.text_rise, 0.0)
};
let px = (x_cursor + x_offset + paint_origin_dx) * h_scale + rise_x;
let py = y_cursor + y_offset + paint_origin_dy + rise_y;
let glyph_transform = combined_base.pre_translate(px, py).pre_scale(scale, scale);
guarded_fill_path(
pixmap,
&path,
paint,
tiny_skia::FillRule::Winding,
glyph_transform,
clip_mask,
);
}
} else {
if char_at_pos.is_whitespace() {
if wmode == 0 {
x_cursor += x_advance + gs.char_space;
if char_at_pos == ' ' && word_space_eligible {
x_cursor += gs.word_space;
}
} else {
y_cursor += y_step + gs.char_space;
if char_at_pos == ' ' && word_space_eligible {
y_cursor += gs.word_space;
}
}
continue;
}
if last_fallback_cluster == Some(cluster) {
if wmode == 0 {
x_cursor += x_advance;
} else {
y_cursor += y_step;
}
continue;
}
last_fallback_cluster = Some(cluster);
if let Some((cjk_id, cjk_arc, cjk_index)) = get_cjk_fallback_cached(self.font_db()) {
if let Some(cjk_cached) = cached_face(cjk_id, cjk_arc, cjk_index) {
let cjk_face = cjk_cached.outline_face();
if let Some(cjk_glyph_id) = cjk_face.glyph_index(char_at_pos) {
let mut cjk_pb = PathBuilder::new();
let mut cjk_builder = SkiaOutlineBuilder(&mut cjk_pb);
if cjk_face.outline_glyph(cjk_glyph_id, &mut cjk_builder).is_some() {
if let Some(cjk_path) = cjk_pb.finish() {
let cjk_scale = font_size / cjk_cached.units_per_em;
let (rise_x, rise_y) = if wmode == 0 {
(0.0, gs.text_rise)
} else {
(gs.text_rise, 0.0)
};
let px = (x_cursor + x_offset + paint_origin_dx) * h_scale + rise_x;
let py = y_cursor + y_offset + paint_origin_dy + rise_y;
let cjk_transform =
combined_base.pre_translate(px, py).pre_scale(cjk_scale, -cjk_scale);
guarded_fill_path(
pixmap,
&cjk_path,
paint,
tiny_skia::FillRule::Winding,
cjk_transform,
clip_mask,
);
has_outline = true;
if let Some(adv) = cjk_face.glyph_hor_advance(cjk_glyph_id) {
x_advance_override = Some(adv / cjk_cached.units_per_em * font_size);
}
}
}
}
}
}
if !has_outline {
let reason = if glyph_id == 0 {
"not mapped by font or CJK fallback"
} else {
"no outline in font or CJK fallback"
};
unicode_dropped.record(reason, char_at_pos as u32, glyph_id as u16);
}
}
if wmode == 0 {
x_cursor += x_advance_override.unwrap_or(x_advance);
x_cursor += gs.char_space;
if char_at_pos == ' ' && word_space_eligible {
x_cursor += gs.word_space;
}
} else {
y_cursor += y_step;
y_cursor += gs.char_space;
if char_at_pos == ' ' && word_space_eligible {
y_cursor += gs.word_space;
}
}
}
self.report_drops(
&unicode_dropped,
font_info.map(|f| f.base_font.as_str()).unwrap_or("<system fallback>"),
);
Ok(if wmode == 0 { x_cursor } else { y_cursor })
}
fn render_cid_direct(
&self,
pixmap: &mut Pixmap,
bytes: &[u8],
font_info: &crate::fonts::FontInfo,
font_data: &[u8],
index: u32,
paint: &Paint,
base_transform: Transform,
gs: &GraphicsState,
clip_mask: Option<&tiny_skia::Mask>,
) -> Result<f32> {
let font_size = gs.font_size;
let h_scale = gs.horizontal_scaling / 100.0;
let font_bytes = outlineable_font_bytes(font_data, index);
let ttf_face = OutlineFace::new(&font_bytes, index)
.ok_or_else(|| Error::InvalidPdf("Failed to parse embedded font".to_string()))?;
let units_per_em = ttf_face.units_per_em();
let scale = font_size / units_per_em;
let text_transform = Transform::from_row(
gs.text_matrix.a,
gs.text_matrix.b,
gs.text_matrix.c,
gs.text_matrix.d,
gs.text_matrix.e,
gs.text_matrix.f,
);
let combined_base = base_transform.pre_concat(text_transform);
let mut x_cursor: f32 = 0.0;
let mut y_cursor: f32 = 0.0;
let wmode = gs.text_wmode;
let mut dropped = GlyphDropTally::default();
for (char_code, bytes_consumed) in TextCharIter::new(bytes, Some(font_info)) {
let gid = if font_info.subtype == "Type0" {
match &font_info.cid_to_gid_map {
Some(crate::fonts::CIDToGIDMap::Identity) => char_code,
Some(crate::fonts::CIDToGIDMap::Explicit(map)) => *map.get(char_code as usize).unwrap_or(&0),
None => char_code,
}
} else if let Some(cff_map) = &font_info.cff_gid_map {
*cff_map.get(&(char_code as u8)).unwrap_or(&0)
} else if font_info.cid_to_gid_map.is_none() {
ttf_face.cmap_byte_to_gid(char_code as u8).unwrap_or(0)
} else {
match &font_info.cid_to_gid_map {
Some(crate::fonts::CIDToGIDMap::Identity) => char_code,
Some(crate::fonts::CIDToGIDMap::Explicit(map)) => *map.get(char_code as usize).unwrap_or(&0),
None => char_code,
}
};
let cid = char_code;
let pdf_width = font_info.get_glyph_width(cid);
let x_advance = pdf_width * font_size / 1000.0;
let (y_step, paint_origin_dx, paint_origin_dy) = if wmode == 1 {
let m = font_info.get_vertical_metrics(cid);
(
m.w1y * font_size / 1000.0,
-m.v_x * font_size / 1000.0,
-m.v_y * font_size / 1000.0,
)
} else {
(0.0, 0.0, 0.0)
};
let char_str = font_info.char_to_unicode(cid as u32).unwrap_or_default();
let char_at_pos = char_str.chars().next().unwrap_or('\0');
if gid == 0 && !char_at_pos.is_whitespace() {
dropped.record("no glyph id", u32::from(char_code), gid);
}
if gid != 0 || char_at_pos.is_whitespace() {
if !char_at_pos.is_whitespace() {
let mut pb = PathBuilder::new();
let mut builder = SkiaOutlineBuilder(&mut pb);
let outlined = ttf_face.outline_glyph(GlyphId::from(gid), &mut builder).is_some();
if !outlined {
dropped.record("no outline", u32::from(char_code), gid);
}
if outlined {
if let Some(path) = pb.finish() {
let (rise_x, rise_y) = if wmode == 0 {
(0.0, gs.text_rise)
} else {
(gs.text_rise, 0.0)
};
let px = (x_cursor + paint_origin_dx) * h_scale + rise_x;
let py = y_cursor + paint_origin_dy + rise_y;
let glyph_transform = combined_base.pre_translate(px, py).pre_scale(scale, scale);
guarded_fill_path(
pixmap,
&path,
paint,
tiny_skia::FillRule::Winding,
glyph_transform,
clip_mask,
);
}
}
}
}
let word_space_eligible = bytes_consumed == 1 && char_code == 32;
if wmode == 0 {
x_cursor += x_advance + gs.char_space;
if word_space_eligible {
x_cursor += gs.word_space;
}
} else {
y_cursor += y_step + gs.char_space;
if word_space_eligible {
y_cursor += gs.word_space;
}
}
}
self.report_drops(&dropped, &font_info.base_font);
Ok(if wmode == 0 { x_cursor } else { y_cursor })
}
#[cfg(feature = "cjk-render-fallback")]
fn render_substituted_cjk(
&self,
pixmap: &mut Pixmap,
bytes: &[u8],
font_info: &crate::fonts::FontInfo,
collection: crate::fonts::predefined_cidfont::CharacterCollection,
paint: &Paint,
base_transform: Transform,
gs: &GraphicsState,
clip_mask: Option<&tiny_skia::Mask>,
) -> Result<f32> {
let face = match render_cjk_fallback_face() {
Some(f) => f,
None => {
Self::warn_cjk_fallback_unavailable(&font_info.base_font);
return self.measure_only_advance(bytes, font_info, gs);
}
};
let ttf_face = face.outline_face();
let font_size = gs.font_size;
let h_scale = gs.horizontal_scaling / 100.0;
let units_per_em = face.units_per_em;
let scale = font_size / units_per_em;
let text_transform = Transform::from_row(
gs.text_matrix.a,
gs.text_matrix.b,
gs.text_matrix.c,
gs.text_matrix.d,
gs.text_matrix.e,
gs.text_matrix.f,
);
let combined_base = base_transform.pre_concat(text_transform);
let mut x_cursor: f32 = 0.0;
let mut y_cursor: f32 = 0.0;
let wmode = gs.text_wmode;
let mut glyphs_painted: usize = 0;
let mut glyphs_missing: usize = 0;
for (char_code, bytes_consumed) in TextCharIter::new(bytes, Some(font_info)) {
let cid = char_code;
let pdf_width = font_info.get_glyph_width(cid);
let x_advance = pdf_width * font_size / 1000.0;
let (y_step, paint_origin_dx, paint_origin_dy) = if wmode == 1 {
let m = font_info.get_vertical_metrics(cid);
(
m.w1y * font_size / 1000.0,
-m.v_x * font_size / 1000.0,
-m.v_y * font_size / 1000.0,
)
} else {
(0.0, 0.0, 0.0)
};
let mut gid: u16 = 0;
let mut ch: char = '\0';
let unicode = font_info
.to_unicode
.as_ref()
.and_then(|lazy| lazy.get())
.and_then(|cmap| cmap.get(&(cid as u32)).and_then(|s| s.chars().next()))
.filter(|c| !matches!(*c, '\u{FFFD}' | '\u{FFFE}' | '\u{FFFF}'))
.or_else(|| collection.cid_to_unicode(cid).and_then(char::from_u32));
if let Some(c) = unicode {
ch = c;
if let Some(g) = ttf_face.glyph_index(c) {
gid = g.to_u32() as u16;
}
}
let is_whitespace = ch.is_whitespace();
if gid != 0 && !is_whitespace {
let mut pb = PathBuilder::new();
let mut builder = SkiaOutlineBuilder(&mut pb);
if ttf_face.outline_glyph(GlyphId::from(gid), &mut builder).is_some() {
if let Some(path) = pb.finish() {
let (rise_x, rise_y) = if wmode == 0 {
(0.0, gs.text_rise)
} else {
(gs.text_rise, 0.0)
};
let px = (x_cursor + paint_origin_dx) * h_scale + rise_x;
let py = y_cursor + paint_origin_dy + rise_y;
let glyph_transform = combined_base.pre_translate(px, py).pre_scale(scale, scale);
guarded_fill_path(
pixmap,
&path,
paint,
tiny_skia::FillRule::Winding,
glyph_transform,
clip_mask,
);
glyphs_painted += 1;
}
}
} else if !is_whitespace {
glyphs_missing += 1;
}
let word_space_eligible = bytes_consumed == 1 && ch == ' ';
if wmode == 0 {
x_cursor += x_advance + gs.char_space;
if word_space_eligible {
x_cursor += gs.word_space;
}
} else {
y_cursor += y_step + gs.char_space;
if word_space_eligible {
y_cursor += gs.word_space;
}
}
}
if glyphs_missing > 0 {
tracing::trace!(
"CJK substitution painted {} glyphs, skipped {} \
(no Unicode mapping or no glyph in Droid Sans Fallback)",
glyphs_painted,
glyphs_missing
);
}
Ok(if wmode == 0 { x_cursor * h_scale } else { y_cursor })
}
#[cfg(feature = "cjk-render-fallback")]
fn measure_only_advance(
&self,
bytes: &[u8],
font_info: &crate::fonts::FontInfo,
gs: &GraphicsState,
) -> Result<f32> {
Ok(measure_text_bytes(bytes, gs, Some(font_info)))
}
fn render_text_fallback(
&self,
pixmap: &mut Pixmap,
text: &str,
paint: &Paint,
base_transform: Transform,
gs: &GraphicsState,
clip_mask: Option<&tiny_skia::Mask>,
) -> Result<f32> {
let font_size = gs.font_size;
let char_width = font_size * 0.6;
let mut x_cursor: f32 = 0.0;
let h_scale = gs.horizontal_scaling / 100.0;
let text_transform = Transform::from_row(
gs.text_matrix.a,
gs.text_matrix.b,
gs.text_matrix.c,
gs.text_matrix.d,
gs.text_matrix.e,
gs.text_matrix.f,
);
let transform = base_transform.pre_concat(text_transform);
for c in text.chars() {
if !c.is_whitespace() {
let mut pb = PathBuilder::new();
if let Some(rect) =
tiny_skia::Rect::from_xywh(x_cursor * h_scale, 0.0, char_width * 0.8, font_size * 0.8)
{
pb.push_rect(rect);
if let Some(path) = pb.finish() {
guarded_fill_path(pixmap, &path, paint, tiny_skia::FillRule::Winding, transform, clip_mask);
}
}
}
x_cursor += (char_width + gs.char_space) / h_scale;
if c == ' ' {
x_cursor += gs.word_space / h_scale;
}
}
Ok(x_cursor * h_scale)
}
}
impl Default for TextRasterizer {
fn default() -> Self {
Self::new()
}
}
fn measure_text_bytes(bytes: &[u8], gs: &GraphicsState, font_info: Option<&crate::fonts::FontInfo>) -> f32 {
let font_size = gs.font_size;
let h_scale = gs.horizontal_scaling / 100.0;
let wmode = gs.text_wmode;
let mut advance: f32 = 0.0;
if let Some(font) = font_info {
for (code, nbytes) in char_codes_with_len(bytes, font) {
let char_code = if font.subtype == "Type0" {
font.code_to_cid(code)
} else {
u16::try_from(code).unwrap_or(0)
};
let word_space_eligible = nbytes == 1 && code == 0x20;
if wmode == 0 {
let glyph_adv = font.get_glyph_width(char_code) * font_size / 1000.0;
advance += (glyph_adv + gs.char_space) * h_scale;
if word_space_eligible {
advance += gs.word_space * h_scale;
}
} else {
let w1y = font.get_vertical_metrics(char_code).w1y;
let glyph_adv = w1y * font_size / 1000.0;
advance += glyph_adv + gs.char_space;
if word_space_eligible {
advance += gs.word_space;
}
}
}
} else {
let char_width = font_size * 0.6;
for &b in bytes {
if wmode == 0 {
advance += (char_width + gs.char_space) * h_scale;
if b == 0x20 {
advance += gs.word_space * h_scale;
}
} else {
advance += char_width + gs.char_space;
if b == 0x20 {
advance += gs.word_space;
}
}
}
}
advance
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::graphics_state::GraphicsState;
use crate::fonts::{Encoding, FontInfo, VerticalMetrics};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use tracing::Level;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Debug)]
struct CapturedEvent {
level: Level,
fields: BTreeMap<String, String>,
}
#[derive(Clone, Default)]
struct EventCapture(Arc<Mutex<Vec<CapturedEvent>>>);
impl<S> Layer<S> for EventCapture
where
S: tracing::Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _context: tracing_subscriber::layer::Context<'_, S>) {
let mut visitor = FieldCapture::default();
event.record(&mut visitor);
self.0.lock().unwrap().push(CapturedEvent {
level: *event.metadata().level(),
fields: visitor.0,
});
}
}
#[derive(Default)]
struct FieldCapture(BTreeMap<String, String>);
impl tracing::field::Visit for FieldCapture {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_string(), format!("{value:?}"));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
}
fn capture_events(operation: impl FnOnce()) -> Vec<CapturedEvent> {
let capture = EventCapture::default();
let subscriber = tracing_subscriber::registry().with(capture.clone());
tracing::subscriber::with_default(subscriber, operation);
capture.0.lock().unwrap().clone()
}
#[test]
fn empty_glyph_drop_tally_produces_no_warning() {
assert!(GlyphDropTally::default().warning("AnyFont").is_none());
}
#[test]
fn glyph_drop_warning_hides_font_and_preserves_details() {
const SECRET_FONT: &str = "CONFIDENTIAL_FONT_27f4";
const EXPECTED_MESSAGE: &str = "font 'redacted' painted nothing for 3 glyph(s) while advancing the cursor; \
first was code 0x41 (glyph 7): no outline. The page renders with a gap that reads as whitespace \
downstream. Reported once per font per page.";
let _ = crate::extractors::warnings::drain_global_warnings();
let mut tally = GlyphDropTally::default();
tally.record("no outline", 0x41, 7);
tally.record("no glyph id", 0x42, 0);
tally.record("no outline", 0x43, 9);
let rasterizer = TextRasterizer::with_fontdb(Arc::new(fontdb::Database::new()));
let events = capture_events(|| {
for font_name in [SECRET_FONT, "3", "no outline", "font"] {
rasterizer.report_drops(&tally, font_name);
}
});
let warnings = crate::extractors::warnings::drain_global_warnings();
assert_eq!(warnings.len(), 4);
for warning in &warnings {
assert_eq!(
warning.category,
crate::extractors::warnings::WarningCategory::GlyphDropped
);
assert_eq!(warning.message, EXPECTED_MESSAGE);
}
let rendered = format!("{events:?}");
assert!(!rendered.contains(SECRET_FONT));
assert_eq!(
events
.iter()
.filter(|event| {
event.level == Level::WARN
&& event.fields.get("operation").map(String::as_str) == Some("render_glyph")
&& event.fields.get("error_code").map(String::as_str) == Some("glyph_dropped")
})
.count(),
4
);
}
#[test]
fn tracing_macros_do_not_reference_sensitive_pdf_payloads() {
let source = include_str!("text_rasterizer.rs");
let mut invocation = String::new();
let mut collecting = false;
for line in source.lines() {
if line.contains("tracing::") {
invocation.clear();
collecting = true;
}
if collecting {
invocation.push_str(line);
invocation.push('\n');
if line.contains(");") {
for forbidden in ["base_font", "pdf_font_name", "unicode_text"] {
assert!(
!invocation
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
.any(|identifier| identifier == forbidden),
"tracing macro exposes {forbidden}: {invocation}"
);
}
assert!(
!invocation.contains("gs.font_name"),
"tracing macro exposes gs.font_name: {invocation}"
);
collecting = false;
}
}
}
assert!(!collecting, "unterminated tracing macro in source audit");
}
#[test]
fn font_recovery_warnings_hide_pdf_font_names() {
const SECRET_FONT: &str = "CONFIDENTIAL_FONT_8c15";
let events = capture_events(|| {
TextRasterizer::warn_font_not_found(SECRET_FONT);
TextRasterizer::warn_invalid_embedded_font(SECRET_FONT);
TextRasterizer::warn_cjk_fallback_unavailable(SECRET_FONT);
});
let rendered = format!("{events:?}");
assert!(!rendered.contains(SECRET_FONT));
for (operation, error_code) in [
("resolve_font", "font_not_found"),
("parse_embedded_font", "invalid_font_data"),
("load_cjk_fallback", "font_unavailable"),
] {
assert_eq!(
events
.iter()
.filter(|event| {
event.level == Level::WARN
&& event.fields.get("operation").map(String::as_str) == Some(operation)
&& event.fields.get("error_code").map(String::as_str) == Some(error_code)
})
.count(),
1,
"missing exact {operation}/{error_code} warning: {events:#?}"
);
}
}
#[test]
fn glyph_drop_report_is_once_per_font_and_reason_per_page() {
let rasterizer = TextRasterizer::with_fontdb(std::sync::Arc::new(fontdb::Database::new()));
assert!(rasterizer.first_report_for("OncePerPage+UniqueA", "no outline"));
assert!(!rasterizer.first_report_for("OncePerPage+UniqueA", "no outline"));
assert!(rasterizer.first_report_for("OncePerPage+UniqueA", "no glyph id"));
assert!(rasterizer.first_report_for("OncePerPage+UniqueB", "no outline"));
rasterizer.reset_page_warnings();
assert!(rasterizer.first_report_for("OncePerPage+UniqueA", "no outline"));
}
#[cfg(feature = "cjk-render-fallback")]
fn query_droid_fallback(db: &fontdb::Database) -> Option<fontdb::ID> {
db.query(&fontdb::Query {
families: &[fontdb::Family::Name("Droid Sans Fallback")],
weight: fontdb::Weight::NORMAL,
stretch: fontdb::Stretch::Normal,
style: fontdb::Style::Normal,
})
}
#[cfg(feature = "cjk-render-fallback")]
#[test]
fn bundled_cjk_fallback_covers_cjk_without_system_fonts() {
let mut db = fontdb::Database::new();
db.load_font_data(crate::fonts::form_fallback::font_bytes(crate::fonts::form_fallback::Fallback::Cjk).to_vec());
let id = query_droid_fallback(&db).expect("bundled Droid Sans Fallback must be queryable by family name");
let covered = db
.with_face_data(id, |data, index| {
let face = OutlineFace::new(data, index).expect("parse bundled face");
['東', '中', '가'].iter().all(|&c| face.glyph_index(c).is_some())
})
.expect("bundled face data must be present");
assert!(covered, "bundled CJK fallback must cover representative CJK glyphs");
}
#[cfg(feature = "cjk-render-fallback")]
#[test]
fn system_fontdb_registers_cjk_fallback() {
assert!(
query_droid_fallback(&system_fontdb()).is_some(),
"system_fontdb must expose Droid Sans Fallback under cjk-render-fallback"
);
}
fn make_vertical_test_font() -> FontInfo {
FontInfo {
base_font: "TestVertical".to_string(),
subtype: "Type0".to_string(),
encoding: Encoding::Identity,
to_unicode: None,
font_weight: None,
flags: None,
stem_v: None,
ascent: 0.95,
descent: -0.35,
embedded_font_data: None,
truetype_cmap: std::sync::OnceLock::new(),
embedded_glyph_names: std::sync::OnceLock::new(),
is_truetype_font: false,
widths: None,
first_char: None,
last_char: None,
font_matrix_a: 0.001,
default_width: 1000.0,
cid_to_gid_map: Some(crate::fonts::CIDToGIDMap::Identity),
cid_system_info: None,
cid_font_type: Some("CIDFontType2".to_string()),
cid_widths: None,
cid_default_width: 1000.0,
has_explicit_dw: true,
cff_gid_map: None,
multi_char_map: HashMap::new(),
byte_to_char_table: std::sync::OnceLock::new(),
type0_unicode_memo: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
byte_to_width_table: std::sync::OnceLock::new(),
weight_memo: std::sync::OnceLock::new(),
italic_memo: std::sync::OnceLock::new(),
std14_memo: std::sync::OnceLock::new(),
diff_glyph_names: HashMap::new(),
wmode: 1,
cid_vertical_metrics: None,
cid_default_vertical_metrics: VerticalMetrics::SPEC_DEFAULT,
cjk_substitution: None,
embedded_cid_map: None,
}
}
fn make_uni_cns_ucs2_test_font() -> FontInfo {
let mut font = make_vertical_test_font();
font.base_font = "MSungStd-Light-Acro".to_string();
font.encoding = Encoding::Standard("UniCNS-UCS2-H".to_string());
font.wmode = 0;
font.cid_font_type = Some("CIDFontType0".to_string());
font.cid_to_gid_map = None;
font.cid_system_info = Some(crate::fonts::CIDSystemInfo {
registry: "Adobe".to_string(),
ordering: "CNS1".to_string(),
supplement: 3,
});
font.cid_default_width = 1000.0;
font.has_explicit_dw = true;
font.cid_widths = Some(HashMap::from([
(1, 250.0),
(2, 250.0),
(3, 408.0),
(4, 668.0),
(5, 490.0),
(6, 875.0),
(7, 698.0),
(8, 250.0),
(9, 240.0),
(10, 240.0),
(11, 417.0),
(12, 667.0),
(13, 250.0),
(14, 313.0),
(15, 250.0),
(16, 520.0),
(17, 500.0),
(18, 500.0),
(19, 500.0),
(20, 500.0),
(21, 500.0),
(22, 500.0),
(23, 500.0),
(24, 500.0),
(25, 500.0),
(26, 500.0),
(27, 250.0),
(28, 250.0),
(29, 667.0),
(30, 667.0),
(31, 667.0),
(32, 396.0),
(33, 921.0),
(34, 677.0),
(35, 615.0),
(36, 719.0),
(37, 760.0),
(38, 625.0),
(39, 552.0),
(40, 771.0),
(41, 802.0),
(42, 354.0),
(43, 354.0),
(44, 781.0),
(45, 604.0),
(46, 927.0),
(47, 750.0),
(48, 823.0),
(49, 563.0),
(50, 823.0),
(51, 729.0),
(52, 542.0),
(53, 698.0),
(54, 771.0),
(55, 729.0),
(56, 948.0),
(57, 771.0),
(58, 677.0),
(59, 635.0),
(60, 344.0),
(61, 520.0),
(62, 344.0),
(63, 469.0),
(64, 500.0),
(65, 250.0),
(66, 469.0),
(67, 521.0),
(68, 427.0),
(69, 521.0),
(70, 438.0),
(71, 271.0),
(72, 469.0),
(73, 531.0),
(74, 250.0),
(75, 250.0),
(76, 458.0),
(77, 240.0),
(78, 802.0),
(79, 531.0),
(80, 500.0),
(81, 521.0),
(82, 521.0),
(83, 365.0),
(84, 333.0),
(85, 292.0),
(86, 521.0),
(87, 458.0),
(88, 677.0),
(89, 479.0),
(90, 458.0),
(91, 427.0),
(92, 480.0),
(93, 496.0),
(94, 480.0),
(95, 667.0),
]));
font
}
#[test]
fn measure_text_bytes_predefined_cmap_resolves_code_to_cid_not_the_raw_code() {
let font = make_uni_cns_ucs2_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 18.0;
let advance = measure_text_bytes(b"\x00T\x00h\x00e", &gs, Some(&font));
assert!(
(advance - 30.006).abs() < 0.01,
"expected the CID-correct ~30.0pt advance for \"The\" under UniCNS-UCS2-H, got {advance}"
);
assert!(
(advance - 41.994).abs() > 1.0,
"advance must NOT match the pre-fix code-as-CID ~42.0pt result, got {advance}"
);
}
#[test]
fn measure_text_bytes_predefined_cmap_pins_the_dw_fallback_half_of_the_bug() {
let font = make_uni_cns_ucs2_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 18.0;
let advance = measure_text_bytes(b"\x00a", &gs, Some(&font));
assert!(
(advance - 8.442).abs() < 0.01,
"expected the CID-correct ~8.442pt advance ('a' = CID 66, width 469/1000 em), got {advance}"
);
assert!(
(advance - 18.0).abs() > 1.0,
"advance must NOT match the pre-fix /DW-fallback ~18.0pt result, got {advance}"
);
}
#[test]
fn measure_text_bytes_identity_h_is_unaffected_by_code_to_cid() {
let mut font = make_vertical_test_font();
font.wmode = 0;
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 0;
let bytes: &[u8] = &[0x00, 0x01, 0x00, 0x02];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance - 24.0).abs() < 0.01,
"Identity-H advance must be unchanged: expected 24.0, got {advance}"
);
}
#[test]
fn measure_text_bytes_advances_along_y_in_vertical_mode() {
let font = make_vertical_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 1;
let bytes: &[u8] = &[0x00, 0x01, 0x00, 0x02];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance.abs() - 24.0).abs() < 0.01,
"expected ~|24.0| advance in vertical mode, got {}",
advance
);
assert!(
advance < 0.0,
"vertical advance must be negative (spec default w1y = -1000), got {}",
advance
);
}
#[test]
fn measure_text_bytes_advances_along_x_in_horizontal_mode() {
let font = make_vertical_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 0;
let bytes: &[u8] = &[0x00, 0x01, 0x00, 0x02];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance - 24.0).abs() < 0.01,
"expected ~24.0 advance in horizontal mode, got {}",
advance
);
assert!(advance > 0.0, "horizontal advance must be positive");
}
#[test]
fn measure_text_bytes_ignores_tz_in_vertical_mode() {
let font = make_vertical_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 1;
gs.horizontal_scaling = 200.0;
let bytes: &[u8] = &[0x00, 0x01];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance.abs() - 12.0).abs() < 0.01,
"Tz=200 must NOT scale vertical advance: expected 12, got {}",
advance.abs()
);
}
#[test]
fn measure_text_bytes_vertical_tc_tw_skip_tz() {
let font = make_vertical_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 1;
gs.horizontal_scaling = 200.0;
gs.char_space = 3.0;
let bytes: &[u8] = &[0x00, 0x01];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
((-advance) - 9.0).abs() < 0.01,
"vertical Tc must NOT pick up Tz: expected -9, got {}",
advance
);
}
fn make_simple_test_font() -> FontInfo {
let mut font = make_vertical_test_font();
font.subtype = "Type1".to_string();
font.encoding = Encoding::Standard("WinAnsiEncoding".to_string());
font.cid_to_gid_map = None;
font.cid_font_type = None;
font.wmode = 0;
font
}
#[test]
fn measure_text_bytes_skips_tw_for_multibyte_cid_32() {
let font = make_vertical_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 0;
gs.word_space = 100.0;
let bytes: &[u8] = &[0x00, 0x20];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance - 12.0).abs() < 0.01,
"Tw must not apply to a 2-byte CID 32, expected 12.0, got {}",
advance
);
}
#[test]
fn measure_text_bytes_applies_tw_for_single_byte_code_32() {
let font = make_simple_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 0;
gs.word_space = 100.0;
let bytes: &[u8] = &[0x20];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
assert!(
(advance - 112.0).abs() < 0.01,
"Tw must apply to a single-byte code 32, expected 112.0, got {}",
advance
);
}
#[test]
fn measure_tj_array_aggregates_vertical_advance() {
use crate::content::TextElement;
let font = make_vertical_test_font();
let mut font_cache: HashMap<String, Arc<crate::fonts::FontInfo>> = HashMap::new();
font_cache.insert("F1".to_string(), Arc::new(font));
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 1;
gs.font_name = Some("F1".to_string());
let rasterizer = TextRasterizer::new();
let array = vec![
TextElement::String(vec![0x00, 0x01]),
TextElement::Offset(-250.0),
TextElement::String(vec![0x00, 0x02]),
];
let total = rasterizer.measure_tj_array(&array, &gs, &font_cache);
assert!(
(total - (-21.0)).abs() < 0.01,
"measure_tj_array total should be -21 in vertical mode, got {}",
total
);
}
#[cfg(feature = "cjk-render-fallback")]
#[test]
fn substituted_cjk_advance_applies_horizontal_scaling() {
let mut font = make_vertical_test_font();
font.wmode = 0;
font.cjk_substitution = Some(crate::fonts::predefined_cidfont::CharacterCollection::AdobeJapan1);
let rasterizer = TextRasterizer::with_fontdb(std::sync::Arc::new(fontdb::Database::new()));
let mut pixmap = Pixmap::new(16, 16).expect("pixmap");
let paint = Paint::default();
let bytes: &[u8] = &[0x04, 0xB0, 0x04, 0xB0];
let advance_at = |h_scaling: f32, pixmap: &mut Pixmap| {
let mut gs = GraphicsState::new();
gs.font_size = 10.0;
gs.text_wmode = 0;
gs.horizontal_scaling = h_scaling;
rasterizer
.render_substituted_cjk(
pixmap,
bytes,
&font,
crate::fonts::predefined_cidfont::CharacterCollection::AdobeJapan1,
&paint,
Transform::identity(),
&gs,
None,
)
.expect("substituted render")
};
let full = advance_at(100.0, &mut pixmap);
let half = advance_at(50.0, &mut pixmap);
assert!((full - 20.0).abs() < 0.01, "Th=100% advance should be 20.0, got {full}");
assert!(
(half - 10.0).abs() < 0.01,
"Th=50% must halve the returned advance (§9.4.4 tx·Th): got {half}, full was {full}"
);
}
}