diff --git a/Cargo.toml b/Cargo.toml index 3d55ec6d..05d4e74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,10 @@ required-features = [] name = "customfont" required-features = [] +[[example]] +name = "variable_font" +required-features = ["text_layout"] + [[example]] name = "graphics" required-features = [] diff --git a/examples/variable_font.rs b/examples/variable_font.rs new file mode 100644 index 00000000..6e24196d --- /dev/null +++ b/examples/variable_font.rs @@ -0,0 +1,67 @@ +use std::{env, error::Error, fs}; + +use printpdf::{ + FontVariationSettings, FontVariationTag, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, + PdfSaveOptions, Point, Pt, TextItem, +}; + +fn main() -> Result<(), Box> { + let font_path = env::args() + .nth(1) + .ok_or("usage: cargo run --example variable_font -- path/to/variable-font.ttf")?; + let font_bytes = fs::read(font_path)?; + let mut warnings = Vec::new(); + let mut document = PdfDocument::new("Variable font instances"); + + let light = document.add_variable_font( + &font_bytes, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 300.0), + &mut warnings, + )?; + let bold = document.add_variable_font( + &font_bytes, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 800.0), + &mut warnings, + )?; + + let page = PdfPage::new( + Mm(210.0), + Mm(297.0), + vec![ + Op::StartTextSection, + Op::SetTextCursor { + pos: Point::new(Mm(20.0), Mm(260.0)), + }, + Op::SetFont { + font: PdfFontHandle::External(light), + size: Pt(24.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Weight 300".into())], + }, + Op::SetTextCursor { + pos: Point::new(Mm(20.0), Mm(240.0)), + }, + Op::SetFont { + font: PdfFontHandle::External(bold), + size: Pt(24.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Weight 800".into())], + }, + Op::EndTextSection, + ], + ); + + let pdf = document + .with_pages(vec![page]) + .save(&PdfSaveOptions::default(), &mut warnings); + fs::write("variable_font.pdf", pdf)?; + + for warning in warnings { + eprintln!("{}", warning.msg); + } + Ok(()) +} diff --git a/src/font.rs b/src/font.rs index 5b37db56..924d228c 100644 --- a/src/font.rs +++ b/src/font.rs @@ -1,5 +1,7 @@ use std::{ collections::btree_map::BTreeMap, + fmt, + str::FromStr, vec::Vec, }; @@ -153,6 +155,563 @@ pub struct FontMetrics { pub descent: i16, } +/// A four-byte OpenType variation-axis tag such as `wght` or `opsz`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FontVariationTag([u8; 4]); + +impl FontVariationTag { + pub const WGHT: Self = Self(*b"wght"); + pub const WDTH: Self = Self(*b"wdth"); + pub const OPSZ: Self = Self(*b"opsz"); + pub const SLNT: Self = Self(*b"slnt"); + pub const ITAL: Self = Self(*b"ital"); + + /// Construct a tag after validating the OpenType tag syntax. + pub fn new(bytes: [u8; 4]) -> Result { + validate_variation_tag(bytes)?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } + + pub const fn as_u32(&self) -> u32 { + u32::from_be_bytes(self.0) + } +} + +impl TryFrom<&str> for FontVariationTag { + type Error = VariableFontError; + + fn try_from(value: &str) -> Result { + let bytes: [u8; 4] = value + .as_bytes() + .try_into() + .map_err(|_| VariableFontError::InvalidTag(value.to_string()))?; + Self::new(bytes) + } +} + +impl FromStr for FontVariationTag { + type Err = VariableFontError; + + fn from_str(s: &str) -> Result { + Self::try_from(s) + } +} + +impl fmt::Display for FontVariationTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Construction guarantees ASCII bytes. + let tag = std::str::from_utf8(&self.0).map_err(|_| fmt::Error)?; + f.write_str(tag) + } +} + +fn validate_variation_tag(bytes: [u8; 4]) -> Result<(), VariableFontError> { + let first_is_letter = bytes[0].is_ascii_alphabetic(); + let valid_bytes = bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b' '); + let spaces_are_trailing = bytes + .iter() + .position(|byte| *byte == b' ') + .map(|first_space| bytes[first_space..].iter().all(|byte| *byte == b' ')) + .unwrap_or(true); + + if first_is_letter && valid_bytes && spaces_are_trailing { + Ok(()) + } else { + Err(VariableFontError::InvalidTag( + String::from_utf8_lossy(&bytes).into_owned(), + )) + } +} + +/// Metadata for one axis declared by a variable font's `fvar` table. +#[derive(Debug, Clone, PartialEq)] +pub struct FontVariationAxis { + pub tag: FontVariationTag, + pub name: Option, + pub min_value: f32, + pub default_value: f32, + pub max_value: f32, + pub hidden: bool, +} + +/// User-space coordinates selecting an instance of a variable font. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FontVariationSettings { + pub coordinates: BTreeMap, +} + +impl FontVariationSettings { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, tag: FontVariationTag, value: f32) -> Option { + self.coordinates.insert(tag, value) + } + + pub fn with(mut self, tag: FontVariationTag, value: f32) -> Self { + self.insert(tag, value); + self + } +} + +/// A parsed, PDF-compatible static instance derived from a variable font. +#[cfg(feature = "text_layout")] +#[derive(Debug, Clone)] +pub struct VariableFontInstance { + pub font: ParsedFont, + /// Effective coordinates after defaults, clamping, and Fixed 16.16 rounding. + pub resolved_coordinates: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum VariableFontError { + InvalidFont(String), + InvalidCollectionIndex { index: usize }, + NotVariable, + InvalidTag(String), + UnknownAxis(FontVariationTag), + NonFiniteValue { tag: FontVariationTag, value: f32 }, + UnsupportedOutlineFormat(String), + Instancing(String), + StaticFontParse(String), + PdfConversion(String), +} + +impl fmt::Display for VariableFontError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFont(error) => write!(f, "invalid font: {error}"), + Self::InvalidCollectionIndex { index } => { + write!(f, "invalid font collection index {index}") + } + Self::NotVariable => f.write_str("font does not contain a usable fvar table"), + Self::InvalidTag(tag) => write!(f, "invalid OpenType variation tag {tag:?}"), + Self::UnknownAxis(tag) => write!(f, "font does not declare variation axis {tag}"), + Self::NonFiniteValue { tag, value } => { + write!(f, "variation axis {tag} has non-finite value {value}") + } + Self::UnsupportedOutlineFormat(format) => { + write!(f, "unsupported variable-font outline format: {format}") + } + Self::Instancing(error) => write!(f, "variable-font instancing failed: {error}"), + Self::StaticFontParse(error) => { + write!(f, "failed to parse generated static font: {error}") + } + Self::PdfConversion(error) => { + write!(f, "failed to create a PDF-compatible static font: {error}") + } + } + } +} + +impl std::error::Error for VariableFontError {} + +/// Inspect the axes declared by one face of a variable font. +pub fn font_variation_axes( + bytes: &[u8], + font_index: usize, +) -> Result, VariableFontError> { + use allsorts::{ + binary::read::ReadScope, + font_data::FontData, + tables::{variable_fonts::fvar::FvarTable, FontTableProvider, NameTable}, + tag, + }; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))?; + validate_font_collection_index(&font_file, font_index)?; + let provider = font_file + .table_provider(font_index) + .map_err(|_| VariableFontError::InvalidCollectionIndex { index: font_index })?; + let fvar_data = provider + .table_data(tag::FVAR) + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))? + .ok_or(VariableFontError::NotVariable)?; + let fvar = ReadScope::new(&fvar_data) + .read::>() + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))?; + + if fvar.axis_count() == 0 { + return Err(VariableFontError::NotVariable); + } + + let name_data = provider.table_data(tag::NAME).ok().flatten(); + let name_table = name_data + .as_ref() + .and_then(|data| ReadScope::new(data).read::>().ok()); + + fvar.axes() + .map(|axis| { + let tag = FontVariationTag::new(axis.axis_tag.to_be_bytes())?; + let min_value = f32::from(axis.min_value); + let default_value = f32::from(axis.default_value); + let max_value = f32::from(axis.max_value); + if !(min_value <= default_value && default_value <= max_value) { + return Err(VariableFontError::InvalidFont(format!( + "variation axis {tag} has invalid range {min_value}..={max_value} with default {default_value}" + ))); + } + Ok(FontVariationAxis { + tag, + name: name_table + .as_ref() + .and_then(|table| table.string_for_id(axis.axis_name_id)), + min_value, + default_value, + max_value, + hidden: axis.flags & 0x0001 != 0, + }) + }) + .collect() +} + +/// Materialize a variable font as static sfnt bytes suitable for parsing and PDF embedding. +/// +/// Omitted axes use their font-defined defaults. Out-of-range values are clamped and reported +/// through `warnings`. CFF2 input is converted to a static CFF1-flavored OpenType font. +pub fn instantiate_variable_font_bytes( + bytes: &[u8], + font_index: usize, + settings: &FontVariationSettings, + warnings: &mut Vec, +) -> Result<(Vec, BTreeMap), VariableFontError> { + use allsorts::{ + binary::read::ReadScope, + font_data::FontData, + tables::{variable_fonts::fvar::FvarTable, Fixed, FontTableProvider}, + tag, + }; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))?; + validate_font_collection_index(&font_file, font_index)?; + let provider = font_file + .table_provider(font_index) + .map_err(|_| VariableFontError::InvalidCollectionIndex { index: font_index })?; + let fvar_data = provider + .table_data(tag::FVAR) + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))? + .ok_or(VariableFontError::NotVariable)?; + let fvar = ReadScope::new(&fvar_data) + .read::>() + .map_err(|error| VariableFontError::InvalidFont(error.to_string()))?; + + if fvar.axis_count() == 0 { + return Err(VariableFontError::NotVariable); + } + + let declared_tags = fvar + .axes() + .map(|axis| FontVariationTag::new(axis.axis_tag.to_be_bytes())) + .collect::, _>>()?; + + for (&tag, &value) in &settings.coordinates { + if !declared_tags.contains(&tag) { + return Err(VariableFontError::UnknownAxis(tag)); + } + if !value.is_finite() { + return Err(VariableFontError::NonFiniteValue { tag, value }); + } + } + + let mut user_instance = Vec::with_capacity(usize::from(fvar.axis_count())); + let mut resolved_coordinates = BTreeMap::new(); + for axis in fvar.axes() { + let axis_tag = FontVariationTag::new(axis.axis_tag.to_be_bytes())?; + let min = f32::from(axis.min_value); + let default = f32::from(axis.default_value); + let max = f32::from(axis.max_value); + if !(min <= default && default <= max) { + return Err(VariableFontError::InvalidFont(format!( + "variation axis {axis_tag} has invalid range {min}..={max} with default {default}" + ))); + } + let requested = settings + .coordinates + .get(&axis_tag) + .copied() + .unwrap_or(default); + let clamped = requested.clamp(min, max); + + if requested != clamped { + warnings.push(crate::PdfWarnMsg::warning( + 0, + 0, + format!( + "Variable font axis {axis_tag} value {requested} was clamped to {clamped} (supported range {min}..={max})" + ), + )); + } + + let fixed = Fixed::from(clamped); + user_instance.push(fixed); + resolved_coordinates.insert(axis_tag, f32::from(fixed)); + } + + let source_is_cff2 = provider.has_table(tag::CFF2); + let source_is_truetype = provider.has_table(tag::GLYF) && provider.has_table(tag::GVAR); + if !source_is_cff2 && !source_is_truetype { + return Err(VariableFontError::UnsupportedOutlineFormat( + "expected glyf/gvar or CFF2 tables".to_string(), + )); + } + + let (mut static_bytes, _) = allsorts::variations::instance(&provider, &user_instance) + .map_err(|error| VariableFontError::Instancing(error.to_string()))?; + + if source_is_cff2 { + static_bytes = convert_static_cff2_to_cff1(&static_bytes)?; + } + validate_static_font_for_pdf(&static_bytes)?; + + Ok((static_bytes, resolved_coordinates)) +} + +fn validate_font_collection_index( + font_file: &allsorts::font_data::FontData<'_>, + font_index: usize, +) -> Result<(), VariableFontError> { + use allsorts::{font_data::FontData, tables::OpenTypeData}; + + let definitely_single_face = match font_file { + FontData::OpenType(font) => matches!(&font.data, OpenTypeData::Single(_)), + FontData::Woff(_) => true, + FontData::Woff2(font) => font.collection_directory.is_none(), + }; + if definitely_single_face && font_index != 0 { + Err(VariableFontError::InvalidCollectionIndex { index: font_index }) + } else { + Ok(()) + } +} + +/// Instantiate and parse a variable font before it enters the text/PDF pipeline. +#[cfg(feature = "text_layout")] +pub fn instantiate_variable_font( + bytes: &[u8], + font_index: usize, + settings: &FontVariationSettings, + warnings: &mut Vec, +) -> Result { + let (static_bytes, resolved_coordinates) = + instantiate_variable_font_bytes(bytes, font_index, settings, warnings)?; + let mut font_warnings = Vec::new(); + let mut parsed_font = + ParsedFont::from_bytes(&static_bytes, 0, &mut font_warnings).ok_or_else(|| { + VariableFontError::StaticFontParse(format_font_parse_warnings(&font_warnings)) + })?; + + forward_font_parse_warnings(font_warnings, warnings); + set_parsed_font_type_from_bytes(&mut parsed_font, &static_bytes)?; + + Ok(VariableFontInstance { + font: parsed_font, + resolved_coordinates, + }) +} + +#[cfg(feature = "text_layout")] +impl crate::PdfDocument { + /// Add a selected variable-font instance as a static PDF font resource. + pub fn add_variable_font( + &mut self, + bytes: &[u8], + font_index: usize, + settings: &FontVariationSettings, + warnings: &mut Vec, + ) -> Result { + let instance = instantiate_variable_font(bytes, font_index, settings, warnings)?; + Ok(self.add_font(&instance.font)) + } +} + +fn convert_static_cff2_to_cff1(bytes: &[u8]) -> Result, VariableFontError> { + use allsorts::{ + binary::read::ReadScope, + font_data::FontData, + subset::{CmapTarget, SubsetProfile}, + tables::{FontTableProvider, MaxpTable}, + tag, + }; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let provider = font_file + .table_provider(0) + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let maxp_data = provider + .read_table_data(tag::MAXP) + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let maxp = ReadScope::new(&maxp_data) + .read::() + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let glyph_ids = (0..maxp.num_glyphs).collect::>(); + let profile = SubsetProfile::Custom(vec![ + tag::CMAP, + tag::HEAD, + tag::HHEA, + tag::HMTX, + tag::MAXP, + tag::NAME, + tag::OS_2, + tag::POST, + tag::GPOS, + tag::GSUB, + tag::GDEF, + tag::VHEA, + tag::VMTX, + tag::CVT, + tag::FPGM, + tag::PREP, + ]); + + allsorts::subset::subset(&provider, &glyph_ids, &profile, CmapTarget::Unicode) + .map_err(|error| VariableFontError::PdfConversion(error.to_string())) +} + +pub(crate) fn validate_static_font_for_pdf(bytes: &[u8]) -> Result<(), VariableFontError> { + validate_static_font_face_for_pdf(bytes, 0) +} + +pub(crate) fn validate_static_font_face_for_pdf( + bytes: &[u8], + font_index: usize, +) -> Result<(), VariableFontError> { + use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider, tag}; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let provider = font_file + .table_provider(font_index) + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + if let Some(table) = unresolved_variation_table(&provider) { + return Err(VariableFontError::PdfConversion(format!( + "font still contains unresolved variation table {}", + allsorts::tag::DisplayTag(table) + ))); + } + if !provider.has_table(tag::GLYF) && !provider.has_table(tag::CFF) { + return Err(VariableFontError::PdfConversion( + "generated font has neither glyf nor CFF outlines".to_string(), + )); + } + Ok(()) +} + +fn unresolved_variation_table(provider: &impl allsorts::tables::FontTableProvider) -> Option { + use allsorts::tag; + + [ + tag::FVAR, + tag::GVAR, + tag::CVAR, + tag::HVAR, + tag::MVAR, + tag::AVAR, + tag::CFF2, + u32::from_be_bytes(*b"VVAR"), + ] + .into_iter() + .find(|table| provider.has_table(*table)) +} + +pub(crate) fn unresolved_variation_table_in_font( + bytes: &[u8], + font_index: usize, +) -> Result, VariableFontError> { + use allsorts::{binary::read::ReadScope, font_data::FontData}; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + let provider = font_file + .table_provider(font_index) + .map_err(|error| VariableFontError::PdfConversion(error.to_string()))?; + Ok(unresolved_variation_table(&provider)) +} + +pub(crate) fn set_parsed_font_type_from_bytes( + parsed_font: &mut ParsedFont, + bytes: &[u8], +) -> Result<(), VariableFontError> { + use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider, tag}; + + let scope = ReadScope::new(bytes); + let font_file = scope + .read::>() + .map_err(|error| VariableFontError::StaticFontParse(error.to_string()))?; + let provider = font_file + .table_provider(0) + .map_err(|error| VariableFontError::StaticFontParse(error.to_string()))?; + + if let Some(cff) = provider + .table_data(tag::CFF) + .map_err(|error| VariableFontError::StaticFontParse(error.to_string()))? + { + #[cfg(feature = "text_layout")] + { + parsed_font.font_type = FontType::OpenTypeCFF(cff.into_owned()); + parsed_font.index_to_cid = (0..parsed_font.num_glyphs) + .map(|glyph_id| (glyph_id, glyph_id)) + .collect(); + } + #[cfg(not(feature = "text_layout"))] + { + let _ = cff; + parsed_font.font_type = FontType::OpenTypeCFF(()); + } + } else { + parsed_font.font_type = FontType::TrueType; + } + Ok(()) +} + +#[cfg(feature = "text_layout")] +fn format_font_parse_warnings(warnings: &[PdfFontParseWarning]) -> String { + warnings + .iter() + .map(|warning| warning.message.as_str()) + .collect::>() + .join("; ") +} + +#[cfg(feature = "text_layout")] +fn forward_font_parse_warnings( + font_warnings: Vec, + warnings: &mut Vec, +) { + use azul_layout::font::parsed::FontParseWarningSeverity; + + warnings.extend(font_warnings.into_iter().filter_map(|warning| { + match warning.severity { + FontParseWarningSeverity::Info => None, + FontParseWarningSeverity::Warning => { + Some(crate::PdfWarnMsg::warning(0, 0, warning.message)) + } + FontParseWarningSeverity::Error => { + Some(crate::PdfWarnMsg::error(0, 0, warning.message)) + } + } + })); +} + /// Result of subsetting a font #[derive(Debug, Clone)] pub struct SubsetFont { @@ -535,9 +1094,11 @@ pub fn subset_font(font: &ParsedFont, glyph_ids: &BTreeMap) -> Result .table_provider(font.original_index) .map_err(|e| e.to_string())?; - // Collect glyph IDs in a consistent order (BTreeMap gives sorted order) - let ids: Vec<_> = glyph_ids.keys().copied().collect(); - + // allsorts requires .notdef (GID 0) first. Remaining IDs stay sorted. + let ids: Vec<_> = std::iter::once(0) + .chain(glyph_ids.keys().copied().filter(|glyph_id| *glyph_id != 0)) + .collect(); + // Use SubsetProfile::Pdf for PDF embedding and CmapTarget::Unicode for Unicode cmap let bytes = allsorts::subset::subset( &provider, @@ -546,15 +1107,13 @@ pub fn subset_font(font: &ParsedFont, glyph_ids: &BTreeMap) -> Result CmapTarget::Unicode, ).map_err(|e| e.to_string())?; - // Build glyph mapping: allsorts subset assigns new GIDs starting at 1 - // (GID 0 is always .notdef), following the order of input glyph IDs + // allsorts assigns new GIDs in input order, with .notdef remaining GID 0. let glyph_mapping: BTreeMap = ids .iter() .enumerate() .filter_map(|(idx, &original_gid)| { glyph_ids.get(&original_gid).map(|&ch| { - // New GID = index + 1 (because GID 0 is .notdef) - let new_gid = (idx + 1) as u16; + let new_gid = idx as u16; (original_gid, (new_gid, ch)) }) }) diff --git a/src/serialize.rs b/src/serialize.rs index 116765fc..417a3714 100644 --- a/src/serialize.rs +++ b/src/serialize.rs @@ -714,9 +714,6 @@ pub(crate) fn translate_operations( // Helper function to encode text items to PDF operations // -// IMPORTANT: This function uses ORIGINAL glyph IDs directly. -// Font subsetting (if enabled) happens at font serialization time, -// and the glyph ID remapping is done there, not here. fn encode_text_items_to_pdf( items: &[TextItem], font_info: Option<&RuntimeFontInfo>, @@ -736,11 +733,13 @@ fn encode_text_items_to_pdf( TextItem::Text(text) => { if let Some(font_info) = font_info { // For custom fonts, convert each character to its glyph ID - // Use original GIDs - subsetting remapping happens at font serialization let bytes: Vec = text.chars() .flat_map(|c| { - font_info.parsed_font.lookup_glyph_index(c as u32) - .unwrap_or(0) + let original_gid = font_info + .parsed_font + .lookup_glyph_index(c as u32) + .unwrap_or(0); + font_info.map_glyph_id(original_gid) .to_be_bytes() }) .collect(); @@ -768,10 +767,11 @@ fn encode_text_items_to_pdf( tj_array.push(Real(*offset)); } TextItem::GlyphIds(glyphs) => { - // Use original glyph IDs directly - // Subsetting remapping happens at font serialization time for codepoint in glyphs { - let bytes = codepoint.gid.to_be_bytes().to_vec(); + let glyph_id = font_info + .map(|font| font.map_glyph_id(codepoint.gid)) + .unwrap_or(codepoint.gid); + let bytes = glyph_id.to_be_bytes().to_vec(); tj_array.push(LoString(bytes, Hexadecimal)); if codepoint.offset != 0.0 { tj_array.push(Real(codepoint.offset)); @@ -807,6 +807,17 @@ fn needs_hex_encoding(bytes: &[u8]) -> bool { #[derive(Debug, Clone)] pub(crate) struct RuntimeFontInfo { pub parsed_font: ParsedFont, + /// Original GID -> embedded subset GID. Empty means identity/full-font embedding. + pub glyph_mapping: BTreeMap, +} + +impl RuntimeFontInfo { + fn map_glyph_id(&self, original_gid: u16) -> u16 { + self.glyph_mapping + .get(&original_gid) + .copied() + .unwrap_or(original_gid) + } } /// Font subsetting information computed at serialization time (when subsetting is enabled) @@ -1151,27 +1162,57 @@ pub(crate) fn prepare_fonts_for_serialization( if glyph_usage.is_empty() { continue; // Skip unused fonts } - - // Always create RuntimeFontInfo for text encoding (uses original GIDs) - font_infos.insert(font_id.clone(), RuntimeFontInfo { - parsed_font: pdf_font.parsed_font.clone(), - }); + + #[cfg(feature = "text_layout")] + let font_index = pdf_font.parsed_font.original_index; + #[cfg(not(feature = "text_layout"))] + let font_index = pdf_font.parsed_font.font_index as usize; + + if let Ok(Some(table)) = crate::font::unresolved_variation_table_in_font( + &pdf_font.parsed_font.original_bytes, + font_index, + ) { + warnings.push(PdfWarnMsg::error( + 0, + 0, + format!( + "Refusing to embed font {} because it contains unresolved variation table {}. Instantiate variable fonts with PdfDocument::add_variable_font before saving", + font_id.0, + allsorts::tag::DisplayTag(table), + ), + )); + continue; + } // Create RuntimeSubsetInfo for font dictionary #[cfg(feature = "text_layout")] - let subset_info = if false && do_subset && + let (subset_info, glyph_mapping) = if do_subset && pdf_font.meta.requires_subsetting && pdf_font.meta.embedding_mode == crate::font::FontEmbeddingMode::Subset { // Try subsetting, fall back to full font if it fails create_subset_runtime_info(font_id, pdf_font, &glyph_usage, warnings) - .unwrap_or_else(|| create_full_font_runtime_info(font_id, pdf_font, &glyph_usage)) + .unwrap_or_else(|| ( + create_full_font_runtime_info(font_id, pdf_font, &glyph_usage), + BTreeMap::new(), + )) } else { // Use full font without subsetting - create_full_font_runtime_info(font_id, pdf_font, &glyph_usage) + ( + create_full_font_runtime_info(font_id, pdf_font, &glyph_usage), + BTreeMap::new(), + ) }; #[cfg(not(feature = "text_layout"))] - let subset_info = create_full_font_runtime_info(font_id, pdf_font, &glyph_usage); + let (subset_info, glyph_mapping) = ( + create_full_font_runtime_info(font_id, pdf_font, &glyph_usage), + BTreeMap::new(), + ); + + font_infos.insert(font_id.clone(), RuntimeFontInfo { + parsed_font: pdf_font.parsed_font.clone(), + glyph_mapping, + }); subset_infos.insert(font_id.clone(), subset_info); } @@ -1186,13 +1227,24 @@ fn create_subset_runtime_info( pdf_font: &crate::font::PdfFont, glyph_usage: &BTreeMap, warnings: &mut Vec, -) -> Option { +) -> Option<(RuntimeSubsetInfo, BTreeMap)> { let subset_result = crate::font::subset_font(&pdf_font.parsed_font, glyph_usage); match subset_result { Ok(subset) => { let mut font_warnings = Vec::new(); - if let Some(subset_font) = ParsedFont::from_bytes(&subset.bytes, 0, &mut font_warnings) { + if let Some(mut subset_font) = ParsedFont::from_bytes(&subset.bytes, 0, &mut font_warnings) { + if let Err(error) = crate::font::set_parsed_font_type_from_bytes( + &mut subset_font, + &subset.bytes, + ) { + warnings.push(PdfWarnMsg::error( + 0, + 0, + format!("Failed to inspect subset font {}: {}", font_id.0, error), + )); + return None; + } let new_glyph_ids: Vec<(u16, char)> = glyph_usage .iter() @@ -1216,14 +1268,23 @@ fn create_subset_runtime_info( } }; - Some(RuntimeSubsetInfo { - original_font: pdf_font.parsed_font.clone(), - subset_font_bytes: subset.bytes, - cid_to_unicode_map, - widths_list: widths, - ascent: subset_font.font_metrics.ascent as i64, - descent: subset_font.font_metrics.descent as i64, - }) + let glyph_mapping = subset + .glyph_mapping + .iter() + .map(|(original_gid, (subset_gid, _))| (*original_gid, *subset_gid)) + .collect(); + + Some(( + RuntimeSubsetInfo { + original_font: pdf_font.parsed_font.clone(), + subset_font_bytes: subset.bytes, + cid_to_unicode_map, + widths_list: widths, + ascent: subset_font.font_metrics.ascent as i64, + descent: subset_font.font_metrics.descent as i64, + }, + glyph_mapping, + )) } else { warnings.push(PdfWarnMsg::error(0, 0, format!("Failed to parse subset font for {}", font_id.0))); @@ -1337,9 +1398,10 @@ fn add_subset_font_to_pdf( // Font stream and CID subtype depend on whether this is TrueType or CFF let (sub_type, font_tuple) = match &subset_info.original_font.font_type { FontType::OpenTypeCFF(_) => { - // CFF font stream must not be compressed + // ParsedFont keeps the complete OpenType wrapper, not a bare CFF table. PDF requires + // /OpenType here; /CIDFontType0C is reserved for a raw CID-keyed CFF program. let font_stream = LoStream::new( - LoDictionary::from_iter(vec![("Subtype", Name("CIDFontType0C".into()))]), + LoDictionary::from_iter(vec![("Subtype", Name("OpenType".into()))]), subset_info.subset_font_bytes.clone(), ) .with_compression(false); diff --git a/tests/assets/variable-fonts/AdwaitaSans-Regular.ttf b/tests/assets/variable-fonts/AdwaitaSans-Regular.ttf new file mode 100644 index 00000000..6fcafd9b Binary files /dev/null and b/tests/assets/variable-fonts/AdwaitaSans-Regular.ttf differ diff --git a/tests/assets/variable-fonts/Cantarell-VF.otf b/tests/assets/variable-fonts/Cantarell-VF.otf new file mode 100644 index 00000000..d45148a7 Binary files /dev/null and b/tests/assets/variable-fonts/Cantarell-VF.otf differ diff --git a/tests/variable_font.rs b/tests/variable_font.rs new file mode 100644 index 00000000..713e8ded --- /dev/null +++ b/tests/variable_font.rs @@ -0,0 +1,376 @@ +use allsorts::{binary::read::ReadScope, font_data::FontData, tables::FontTableProvider, tag}; +use printpdf::{ + font_variation_axes, instantiate_variable_font_bytes, FontVariationSettings, FontVariationTag, + VariableFontError, +}; + +const ADWAITA_SANS_VARIABLE: &[u8] = + include_bytes!("assets/variable-fonts/AdwaitaSans-Regular.ttf"); +const CANTARELL_VARIABLE: &[u8] = include_bytes!("assets/variable-fonts/Cantarell-VF.otf"); + +fn has_table(bytes: &[u8], table: u32) -> bool { + let scope = ReadScope::new(bytes); + let font_file = scope.read::>().unwrap(); + let provider = font_file.table_provider(0).unwrap(); + provider.has_table(table) +} + +#[test] +fn exposes_axes_and_validates_tags() { + let axes = font_variation_axes(ADWAITA_SANS_VARIABLE, 0).unwrap(); + let weight = axes + .iter() + .find(|axis| axis.tag == FontVariationTag::WGHT) + .expect("Adwaita Sans must expose its weight axis"); + + assert!(weight.min_value < weight.default_value); + assert!(weight.default_value < weight.max_value); + assert!(weight.name.is_some()); + assert_eq!( + "wght".parse::().unwrap(), + FontVariationTag::WGHT + ); + assert!("weight".parse::().is_err()); + assert!("1bad".parse::().is_err()); + assert!("a b ".parse::().is_err()); +} + +#[test] +fn rejects_unknown_axes_and_non_finite_values() { + let unknown = "TEST".parse::().unwrap(); + let settings = FontVariationSettings::new().with(unknown, 1.0); + let error = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &settings, &mut Vec::new()) + .unwrap_err(); + assert_eq!(error, VariableFontError::UnknownAxis(unknown)); + + let settings = FontVariationSettings::new().with(FontVariationTag::WGHT, f32::NAN); + assert!(matches!( + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &settings, &mut Vec::new(),), + Err(VariableFontError::NonFiniteValue { .. }) + )); +} + +#[test] +fn creates_distinct_static_truetype_instances() { + let light = FontVariationSettings::new().with(FontVariationTag::WGHT, 300.0); + let bold = FontVariationSettings::new().with(FontVariationTag::WGHT, 800.0); + let (light_bytes, light_coordinates) = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &light, &mut Vec::new()).unwrap(); + let (bold_bytes, bold_coordinates) = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &bold, &mut Vec::new()).unwrap(); + + assert_ne!(light_bytes, bold_bytes); + assert_eq!(light_coordinates[&FontVariationTag::WGHT], 300.0); + assert_eq!(bold_coordinates[&FontVariationTag::WGHT], 800.0); + assert!(has_table(&light_bytes, tag::GLYF)); + assert!(!has_table(&light_bytes, tag::FVAR)); + assert!(!has_table(&light_bytes, tag::GVAR)); + assert!(!has_table(&light_bytes, tag::HVAR)); + assert!(!has_table(&light_bytes, tag::MVAR)); +} + +#[test] +fn default_instances_are_deterministic_and_static() { + let settings = FontVariationSettings::new(); + let first = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &settings, &mut Vec::new()) + .unwrap(); + let second = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &settings, &mut Vec::new()) + .unwrap(); + + assert_eq!(first, second); + assert!(first.1.contains_key(&FontVariationTag::WGHT)); + assert!(!has_table(&first.0, tag::FVAR)); +} + +#[test] +fn rejects_static_fonts_and_invalid_collection_indices() { + let static_font = instantiate_variable_font_bytes( + ADWAITA_SANS_VARIABLE, + 0, + &FontVariationSettings::new(), + &mut Vec::new(), + ) + .unwrap() + .0; + assert_eq!( + font_variation_axes(&static_font, 0).unwrap_err(), + VariableFontError::NotVariable + ); + assert!(matches!( + font_variation_axes(ADWAITA_SANS_VARIABLE, 1), + Err(VariableFontError::InvalidCollectionIndex { index: 1 }) + )); +} + +#[test] +fn clamps_coordinates_and_reports_the_effective_value() { + let axes = font_variation_axes(ADWAITA_SANS_VARIABLE, 0).unwrap(); + let weight = axes + .iter() + .find(|axis| axis.tag == FontVariationTag::WGHT) + .unwrap(); + let settings = + FontVariationSettings::new().with(FontVariationTag::WGHT, weight.max_value + 10_000.0); + let mut warnings = Vec::new(); + let (_, coordinates) = + instantiate_variable_font_bytes(ADWAITA_SANS_VARIABLE, 0, &settings, &mut warnings) + .unwrap(); + + assert_eq!(coordinates[&FontVariationTag::WGHT], weight.max_value); + assert!(warnings + .iter() + .any(|warning| warning.msg.contains("clamped"))); +} + +#[test] +fn converts_cff2_to_static_cff1() { + let axes = font_variation_axes(CANTARELL_VARIABLE, 0).unwrap(); + assert!(axes.iter().any(|axis| axis.tag == FontVariationTag::WGHT)); + + let settings = FontVariationSettings::new().with(FontVariationTag::WGHT, 700.0); + let (bytes, coordinates) = + instantiate_variable_font_bytes(CANTARELL_VARIABLE, 0, &settings, &mut Vec::new()).unwrap(); + + assert_eq!(coordinates[&FontVariationTag::WGHT], 700.0); + assert!(has_table(&bytes, tag::CFF)); + assert!(!has_table(&bytes, tag::CFF2)); + assert!(!has_table(&bytes, tag::FVAR)); + assert!(!has_table(&bytes, tag::HVAR)); + assert!(!has_table(&bytes, tag::MVAR)); +} + +#[cfg(feature = "text_layout")] +#[test] +fn registers_two_instances_and_embeds_static_fonts() { + use printpdf::{ + FontType, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfParseOptions, PdfSaveOptions, Pt, + TextItem, + }; + + let mut document = PdfDocument::new("Variable font test"); + let mut warnings = Vec::new(); + let light_id = document + .add_variable_font( + ADWAITA_SANS_VARIABLE, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 300.0), + &mut warnings, + ) + .unwrap(); + let bold_id = document + .add_variable_font( + ADWAITA_SANS_VARIABLE, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 800.0), + &mut warnings, + ) + .unwrap(); + + assert_ne!( + document.resources.fonts.map[&light_id] + .parsed_font + .original_bytes, + document.resources.fonts.map[&bold_id] + .parsed_font + .original_bytes + ); + assert!(matches!( + document.resources.fonts.map[&light_id] + .parsed_font + .font_type, + FontType::TrueType + )); + + document.pages.push(PdfPage::new( + Mm(210.0), + Mm(297.0), + vec![ + Op::StartTextSection, + Op::SetFont { + font: PdfFontHandle::External(light_id), + size: Pt(18.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Light".to_string())], + }, + Op::SetFont { + font: PdfFontHandle::External(bold_id), + size: Pt(18.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Bold".to_string())], + }, + Op::EndTextSection, + ], + )); + + let pdf = document.save(&PdfSaveOptions::default(), &mut warnings); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let reparsed = PdfDocument::parse(&pdf, &PdfParseOptions::default(), &mut warnings).unwrap(); + let extracted = reparsed.extract_text().into_iter().flatten().collect::(); + assert!(extracted.contains("Light")); + assert!(extracted.contains("Bold")); + let embedded_sfnt = parsed + .objects + .values() + .filter_map(|object| object.as_stream().ok()) + .filter(|stream| { + stream.content.starts_with(&[0, 1, 0, 0]) + || stream.content.starts_with(b"true") + || stream.content.starts_with(b"OTTO") + }) + .collect::>(); + + assert_eq!(embedded_sfnt.len(), 2); + for stream in embedded_sfnt { + assert!(!has_table(&stream.content, tag::FVAR)); + assert!(!has_table(&stream.content, tag::GVAR)); + } +} + +#[cfg(feature = "text_layout")] +#[test] +fn cff2_document_instance_uses_opentype_font_stream() { + use printpdf::{ + FontType, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Pt, TextItem, + }; + + let mut document = PdfDocument::new("CFF2 variable font test"); + let mut warnings = Vec::new(); + let font_id = document + .add_variable_font( + CANTARELL_VARIABLE, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 650.0), + &mut warnings, + ) + .unwrap(); + assert!(matches!( + document.resources.fonts.map[&font_id].parsed_font.font_type, + FontType::OpenTypeCFF(_) + )); + document.pages.push(PdfPage::new( + Mm(210.0), + Mm(297.0), + vec![ + Op::StartTextSection, + Op::SetFont { + font: PdfFontHandle::External(font_id), + size: Pt(18.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Cantarell".to_string())], + }, + Op::EndTextSection, + ], + )); + + let pdf = document.save(&PdfSaveOptions::default(), &mut warnings); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + assert!(parsed.objects.values().any(|object| { + let Ok(stream) = object.as_stream() else { + return false; + }; + stream + .dict + .get(b"Subtype") + .ok() + .and_then(|value| value.as_name().ok()) + == Some(b"OpenType".as_slice()) + && stream.content.starts_with(b"OTTO") + })); +} + +#[cfg(feature = "text_layout")] +#[test] +fn full_embedding_also_uses_only_static_instance_bytes() { + use printpdf::{Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Pt, TextItem}; + + let mut document = PdfDocument::new("Full variable font embedding"); + let mut warnings = Vec::new(); + let font_id = document + .add_variable_font( + ADWAITA_SANS_VARIABLE, + 0, + &FontVariationSettings::new().with(FontVariationTag::WGHT, 550.0), + &mut warnings, + ) + .unwrap(); + document.pages.push(PdfPage::new( + Mm(100.0), + Mm(100.0), + vec![ + Op::StartTextSection, + Op::SetFont { + font: PdfFontHandle::External(font_id), + size: Pt(12.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Static".into())], + }, + Op::EndTextSection, + ], + )); + + let pdf = document.save( + &PdfSaveOptions { + subset_fonts: false, + ..Default::default() + }, + &mut warnings, + ); + let parsed = lopdf::Document::load_mem(&pdf).unwrap(); + let embedded = parsed + .objects + .values() + .filter_map(|object| object.as_stream().ok()) + .find(|stream| { + stream.content.starts_with(&[0, 1, 0, 0]) + || stream.content.starts_with(b"true") + || stream.content.starts_with(b"OTTO") + }) + .expect("embedded font stream"); + assert!(!has_table(&embedded.content, tag::FVAR)); + assert!(!has_table(&embedded.content, tag::GVAR)); +} + +#[cfg(feature = "text_layout")] +#[test] +fn legacy_font_registration_refuses_unresolved_variable_bytes() { + use printpdf::{ + Mm, Op, ParsedFont, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Pt, TextItem, + }; + + let mut parse_warnings = Vec::new(); + let parsed_font = ParsedFont::from_bytes(ADWAITA_SANS_VARIABLE, 0, &mut parse_warnings) + .expect("fixture parses"); + let mut document = PdfDocument::new("Reject unresolved font"); + let font_id = document.add_font(&parsed_font); + document.pages.push(PdfPage::new( + Mm(100.0), + Mm(100.0), + vec![ + Op::StartTextSection, + Op::SetFont { + font: PdfFontHandle::External(font_id), + size: Pt(12.0), + }, + Op::ShowText { + items: vec![TextItem::Text("Variable".into())], + }, + Op::EndTextSection, + ], + )); + + let mut warnings = Vec::new(); + let pdf = document.save(&PdfSaveOptions::default(), &mut warnings); + assert!(warnings + .iter() + .any(|warning| warning.msg.contains("Refusing to embed font"))); + assert!(!pdf + .windows(ADWAITA_SANS_VARIABLE.len()) + .any(|window| window == ADWAITA_SANS_VARIABLE)); +}