#![allow(clippy::collapsible_if, clippy::vec_box)]
use super::{create_fill_paint, guarded_fill_path};
use crate::content::operators::TextElement;
use crate::content::GraphicsState;
use crate::document::PdfDocument;
use crate::error::{Error, Result};
use crate::fonts::unicode_decode::{
char_codes_with_widths, get_byte_mode, ByteMode, DecodePolicy, TextCharIter,
};
use crate::object::Object;
use std::collections::HashMap;
use std::sync::Arc;
use tiny_skia::{Paint, PathBuilder, Pixmap, Transform};
use ttf_parser::OutlineBuilder;
struct SkiaOutlineBuilder<'a>(&'a mut PathBuilder);
impl<'a> OutlineBuilder for SkiaOutlineBuilder<'a> {
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 face = ttf_parser::Face::parse(data, 0).ok()?;
let cmap = face.tables().cmap?;
let mut saw_byte_indexed = false;
let mut saw_unicode = false;
for sub in cmap.subtables {
use ttf_parser::PlatformId;
match sub.platform_id {
PlatformId::Unicode => saw_unicode = true,
PlatformId::Windows if sub.encoding_id == 1 || sub.encoding_id == 10 => {
saw_unicode = true;
},
PlatformId::Macintosh if sub.encoding_id == 0 => saw_byte_indexed = true,
_ => {},
}
}
Some((saw_byte_indexed && !saw_unicode, saw_unicode))
})()
.unwrap_or((false, false))
}
fn cmap_byte_to_gid(face: &ttf_parser::Face, byte: u8) -> Option<u16> {
if let Some(cmap) = face.tables().cmap {
for sub in cmap.subtables {
use ttf_parser::PlatformId;
if matches!(sub.platform_id, PlatformId::Macintosh) && sub.encoding_id == 0 {
if let Some(gid) = sub.glyph_index(byte as u32) {
return Some(gid.0);
}
}
}
}
face.glyph_index(byte as char).map(|g| g.0)
}
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>>,
font: harfrust::FontRef<'static>,
shaper_data: harfrust::ShaperData,
ttf_face: ttf_parser::Face<'static>,
pub units_per_em: f32,
}
unsafe impl Send for CachedFace {}
unsafe impl Sync for CachedFace {}
impl CachedFace {
fn new(data: Arc<Vec<u8>>, index: u32) -> Option<Self> {
let font: harfrust::FontRef<'_> = harfrust::FontRef::from_index(&data, index).ok()?;
let ttf_face: ttf_parser::Face<'_> = ttf_parser::Face::parse(&data, index).ok()?;
let units_per_em = ttf_face.units_per_em() as f32;
let font: harfrust::FontRef<'static> = unsafe { std::mem::transmute(font) };
let ttf_face: ttf_parser::Face<'static> = unsafe { std::mem::transmute(ttf_face) };
let shaper_data = harfrust::ShaperData::new(&font);
Some(CachedFace {
_data: data,
font,
shaper_data,
ttf_face,
units_per_em,
})
}
}
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) {
log::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()
}
#[derive(Default)]
struct GlyphDropTally {
count: usize,
first: Option<(&'static str, u32, u16)>,
}
fn drop_tally_is_expected(gs: &GraphicsState, base_font: &str) -> bool {
gs.render_mode == 3 || gs.render_mode == 7 || base_font.to_uppercase().contains("GLYPHLESS")
}
impl GlyphDropTally {
fn record(&mut self, reason: &'static str, char_code: u32, gid: u16) {
self.count += 1;
self.first.get_or_insert((reason, char_code, gid));
}
fn warning(&self, font_name: &str) -> Option<crate::extractors::warnings::Warning> {
let (reason, char_code, gid) = self.first?;
Some(crate::extractors::warnings::Warning {
category: crate::extractors::warnings::WarningCategory::GlyphDropped,
page: None,
message: format!(
"font '{font_name}' painted nothing for {} glyph(s) while advancing the cursor; \
first was code 0x{char_code:X} (glyph {gid}): {reason}. The page renders with \
a gap that reads as whitespace downstream.",
self.count
),
spec_section: None,
})
}
fn report(&self, font_name: &str, rasterizer: &TextRasterizer) {
let Some(warning) = self.warning(font_name) else {
return;
};
if !rasterizer.first_report_for(font_name) {
return;
}
log::warn!(target: "pdf_oxide::fonts", "{}", warning.message);
crate::extractors::warnings::push_global_warning(warning);
}
}
pub struct TextRasterizer {
fontdb: std::sync::Arc<fontdb::Database>,
warned_fonts: std::sync::Mutex<std::collections::HashSet<String>>,
}
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) -> bool {
match self.warned_fonts.lock() {
Ok(mut warned) => warned.insert(font_name.to_string()),
Err(_) => false,
}
}
#[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 = self.decode_text_to_unicode(text, font_info.as_deref());
log::debug!("Decoded text: '{}' (font={:?})", unicode_text, gs.font_name);
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 {
log::debug!(
"Routing font '{}' through CJK substitution path (collection {:?})",
info.base_font,
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 {
log::debug!(
"Using embedded font '{}' with byte-indexed cmap (simple TrueType subset)",
info.base_font
);
return self.render_cid_direct(
pixmap,
text,
info,
embedded,
0,
&paint,
base_transform,
gs,
clip_mask,
);
}
let cid_keyed_cff = info.subtype == "Type0"
&& info.cid_font_type.as_deref() == Some("CIDFontType0");
if has_unicode_cmap && !cid_keyed_cff {
log::debug!("Using embedded font data for '{}'", info.base_font);
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")
{
log::debug!(
"Using embedded font '{}' with CIDToGIDMap (CIDFontType2)",
info.base_font
);
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"))
{
log::debug!(
"Using embedded CFF font '{}' with direct GID mapping",
info.base_font
);
Some((None, Arc::clone(embedded), 0, true))
} else {
log::debug!(
"Embedded font '{}' lacks usable cmap, falling back to system font",
info.base_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(e) => {
log::warn!(
"Direct CID/CFF rendering failed: {}, falling back to system font",
e
);
if let Some((fb_id, fallback_data, fallback_idx)) =
self.load_font_data(pdf_font_name)
{
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,
);
}
},
}
}
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");
log::warn!(
"No font found for '{}', text may render incorrectly. \
Install common fonts (e.g., liberation-fonts, dejavu-fonts, or noto-fonts).",
font_name
);
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 {
crate::fonts::unicode_decode::decode_text_to_unicode(
bytes,
font,
DecodePolicy {
preserve_unmapped: false,
decompose_ligatures: true,
question_mark_for_invalid: false,
},
)
}
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)
}
#[allow(dead_code)]
fn get_font_info(
&self,
doc: &PdfDocument,
resources: &Object,
font_name: &str,
) -> Result<crate::fonts::FontInfo> {
if let Object::Dictionary(res_dict) = resources {
if let Some(Object::Dictionary(fonts)) = res_dict.get("Font") {
if let Some(font_ref) = fonts.get(font_name) {
let font_obj = doc.resolve_object(font_ref)?;
let info = crate::fonts::FontInfo::from_dict(&font_obj, doc)?;
log::debug!("Resolved font '{}': subtype={}, encoding={:?}, has_to_unicode={}, has_embedded={}",
info.base_font, info.subtype, info.encoding, info.to_unicode.is_some(), info.embedded_font_data.is_some());
return Ok(info);
}
}
}
Err(Error::InvalidPdf(format!("Font {} not found", font_name)))
}
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()) {
log::debug!(
"Matched system font for {}: variant={}, index={}, size={} bytes",
pdf_font_name,
variant,
index,
arc_data.len()
);
return Some((id, arc_data, index));
}
}
}
log::debug!(
"No system font matched for '{}' after trying all fallback variants",
pdf_font_name
);
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_font: Option<harfrust::FontRef<'_>>;
let _local_ttf: Option<ttf_parser::Face<'_>>;
let _local_shaper_data: Option<harfrust::ShaperData>;
let font_ref: &harfrust::FontRef<'_>;
let shaper_data_ref: &harfrust::ShaperData;
let ttf_face_ref: &ttf_parser::Face<'_>;
let units_per_em: f32;
if let Some(ref c) = cached_arc {
_local_font = None;
_local_ttf = None;
_local_shaper_data = None;
font_ref = &c.font;
shaper_data_ref = &c.shaper_data;
ttf_face_ref = &c.ttf_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 {
log::warn!("Failed to create harfrust font from embedded data for '{}', falling back to system 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,
);
}
_local_font = font_opt;
_local_ttf = ttf_parser::Face::parse(&font_data, index).ok();
if _local_ttf.is_none() {
return Err(Error::InvalidPdf(format!("Failed to parse font: {}", pdf_font_name)));
}
font_ref = _local_font.as_ref().unwrap();
ttf_face_ref = _local_ttf.as_ref().unwrap();
units_per_em = ttf_face_ref.units_per_em() as f32;
_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;
log::debug!(
"render_unicode_text: pdf_font={}, units_per_em={}, font_size={}, scale={}",
pdf_font_name,
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" {
TextCharIter::new(bytes, Some(info))
.map(|(cid, _)| cid)
.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 = ttf_face_ref
.outline_glyph(ttf_parser::GlyphId(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) {
if let Some(cjk_glyph_id) = cjk_cached.ttf_face.glyph_index(char_at_pos) {
let mut cjk_pb = PathBuilder::new();
let mut cjk_builder = SkiaOutlineBuilder(&mut cjk_pb);
if cjk_cached
.ttf_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_cached.ttf_face.glyph_hor_advance(cjk_glyph_id)
{
x_advance_override =
Some(adv as f32 / 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;
}
}
}
let base_font = font_info
.map(|f| f.base_font.as_str())
.unwrap_or("<system fallback>");
if !drop_tally_is_expected(gs, base_font) {
unicode_dropped.report(base_font, self);
}
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 ttf_face = ttf_parser::Face::parse(font_data, index)
.map_err(|e| Error::InvalidPdf(format!("Failed to parse embedded font: {}", e)))?;
let units_per_em = ttf_face.units_per_em() as f32;
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 => font_info
.cff_cid_to_gid
.as_ref()
.and_then(|m| m.get(&char_code).copied())
.unwrap_or(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() {
cmap_byte_to_gid(&ttf_face, 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(ttf_parser::GlyphId(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;
}
}
}
if !drop_tally_is_expected(gs, &font_info.base_font) {
dropped.report(&font_info.base_font, self);
}
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 => {
log::warn!(
"Font '{}': CJK predefined-CIDFont substitution unavailable — \
bundled Droid Sans Fallback face failed to load. Falling back \
to .notdef paint with advance-only.",
font_info.base_font
);
return self.measure_only_advance(bytes, font_info, gs);
},
};
let ttf_face = &face.ttf_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.0;
}
}
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(ttf_parser::GlyphId(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 {
log::debug!(
"Font '{}': CJK substitution painted {} glyphs, skipped {} \
(no Unicode mapping or no glyph in Droid Sans Fallback)",
font_info.base_font,
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_widths(bytes, font) {
let char_code = 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::HashMap;
#[test]
fn empty_glyph_drop_tally_produces_no_warning() {
assert!(GlyphDropTally::default().warning("AnyFont").is_none());
}
#[test]
fn glyph_drop_warning_names_font_first_glyph_and_count() {
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 warning = tally
.warning("AAAAAA+Broken")
.expect("recorded drops must warn");
assert_eq!(warning.category, crate::extractors::warnings::WarningCategory::GlyphDropped);
assert!(warning.message.contains("AAAAAA+Broken"));
assert!(warning.message.contains("3 glyph(s)"));
assert!(warning.message.contains("0x41"));
assert!(warning.message.contains("(glyph 7)"));
assert!(warning.message.contains("no outline"));
}
#[test]
fn glyph_drop_report_is_once_per_font_per_page() {
let rasterizer = TextRasterizer::with_fontdb(std::sync::Arc::new(fontdb::Database::new()));
assert!(rasterizer.first_report_for("OncePerPage+UniqueA"));
assert!(!rasterizer.first_report_for("OncePerPage+UniqueA"));
assert!(rasterizer.first_report_for("OncePerPage+UniqueB"));
rasterizer.reset_page_warnings();
assert!(rasterizer.first_report_for("OncePerPage+UniqueA"));
}
#[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 = ttf_parser::Face::parse(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,
cff_cid_to_gid: 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,
}
}
#[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}"
);
}
fn make_utf8_cmap_test_font() -> FontInfo {
let mut font = make_vertical_test_font();
font.encoding = Encoding::Standard("UniFull-UTF8-H".to_string());
font.wmode = 0;
font
}
#[test]
fn measure_text_bytes_applies_tw_for_utf8_cmap_single_byte_space() {
let font = make_utf8_cmap_test_font();
let mut gs = GraphicsState::new();
gs.font_size = 12.0;
gs.text_wmode = 0;
gs.word_space = 100.0;
let bytes: &[u8] = &[0xC3, 0xA9, 0x20];
let advance = measure_text_bytes(bytes, &gs, Some(&font));
let mut gs_no_tw = gs.clone();
gs_no_tw.word_space = 0.0;
let advance_no_tw = measure_text_bytes(bytes, &gs_no_tw, Some(&font));
assert!(
(advance - advance_no_tw - 100.0).abs() < 0.01,
"Tw must apply once for the trailing single-byte space in a UTF-8-CMap run, \
got delta {}",
advance - advance_no_tw
);
}
}