PDF: draw fonts krilla can't subset as outlines instead of failing (#132)

krilla subsets fonts only when the document is finished, so one font its
subsetter rejects ("failed to subset font: malformed font") failed the
whole PDF export. Two of the 365 documents in docxide-pdf's Word
reference corpus hit this on Linux: Caladea Italic and Bold Italic
(ttf-caladea, our metric-compatible stand-in for Cambria) and Noto Sans
New Tai Lue Bold.

When finishing fails on a font, the exporter finds the face krilla
names, logs a warning and writes the document again with that face's
glyphs drawn as filled outlines (FontDb::outline), including in the
watermark; every other font is still embedded as selectable text. At
most 32 faces fall back before the export gives up with the error.

kurbo moves from dev-dependencies to dependencies of wordcraft-pdf (it
was already in the workspace).

Test: a copy of the bundled Source Sans 3 with one glyph turned into a
truncated composite fails the old export exactly as the corpus documents
did; it now exports, with no text glyphs from that font and its glyphs
drawn as paths.

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
naxvog
2026-10-10 06:17:11 -04:00
committed by GitHub
co-authored by Oliver Zieschang Claude Opus 5.5
parent cde88df944
commit fbb688657d
3 changed files with 213 additions and 12 deletions
+1 -1
View File
@@ -14,6 +14,7 @@ wordcraft-geom = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
krilla = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
@@ -22,4 +23,3 @@ log = { workspace = true }
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
hayro-cmap = "0.1"
kurbo = { workspace = true }
+113 -11
View File
@@ -21,7 +21,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::num::NonZeroU16;
use std::sync::Arc;
@@ -29,6 +29,7 @@ use krilla::action::{Action, LinkAction};
use krilla::annotation::{Annotation, LinkAnnotation, Target};
use krilla::color::rgb;
use krilla::destination::XyzDestination;
use krilla::error::KrillaError;
use krilla::geom::{Path, PathBuilder, Point, Size, Transform};
use krilla::image::Image;
use krilla::metadata::{DateTime, Metadata};
@@ -103,6 +104,31 @@ pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Resu
if pages.is_empty() {
return Err(PdfError::NoPages);
}
// krilla subsets fonts only when the document is finished, so one font it can't subset fails
// the whole export. Draw that font's glyphs as outlines instead and write the document again.
let mut outlined = HashSet::new();
loop {
match write(doc, lay, opts, &pages, &outlined)? {
Attempt::Done(bytes) => return Ok(bytes),
Attempt::BadFont(id, msg) if outlined.len() < MAX_OUTLINED_FONTS && outlined.insert(id) => {
log::warn!("PDF: {msg}; its text is drawn as outlines");
}
Attempt::BadFont(_, msg) => return Err(PdfError::Write(msg)),
}
}
}
/// How many unsubsettable fonts we fall back to outlines for before giving up.
const MAX_OUTLINED_FONTS: usize = 32;
/// One try at writing the PDF.
enum Attempt {
Done(Vec<u8>),
/// The face with this id could not be embedded (and why).
BadFont(u32, String),
}
fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], outlined: &HashSet<u32>) -> Result<Attempt, PdfError> {
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, enable_tagging: opts.tagged, ..Default::default() };
let mut pdf = krilla::Document::new_with(settings);
pdf.set_metadata(metadata(doc, opts));
@@ -113,13 +139,14 @@ pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Resu
opts,
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
tags: Vec::new(),
tag_index: HashMap::new(),
links: Vec::new(),
};
if let Some(o) = ex.outline(&pages) {
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
}
for (out, &pi) in pages.iter().enumerate() {
@@ -139,7 +166,45 @@ pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Resu
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
match pdf.finish() {
Ok(bytes) => Ok(Attempt::Done(bytes)),
Err(KrillaError::Font(font, msg)) => {
let bad = ex.fonts.iter().find(|(_, (_, f))| f.as_ref() == Some(&font));
match bad {
Some((id, (face, _))) => Ok(Attempt::BadFont(*id, format!("font {} {} could not be embedded ({msg})", face.family, face.style))),
None => Err(PdfError::Write(format!("a font could not be embedded ({msg})"))),
}
}
Err(e) => Err(PdfError::Write(format!("{e:?}"))),
}
}
/// The outlines of `glyphs` (glyph id, pen position) at `size`, as one path in page coordinates.
fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Option<Path> {
let db = wordcraft_fonts::FontDb::global();
let scale = f64::from(size) / face.upem.max(1.0);
let mut pb = PathBuilder::new();
for &(g, x, y) in glyphs {
// Outlines are in font units, y-down: scale to the size, move to the pen position.
let mut outline = (*db.outline(face, g)).clone();
outline.apply_affine(kurbo::Affine::translate((f64::from(x), f64::from(y))) * kurbo::Affine::scale(scale));
append_path(&mut pb, &outline);
}
pb.finish()
}
/// Add the segments of a kurbo path to a krilla path.
fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) {
let f = |v: f64| v as f32;
for el in path.elements() {
match *el {
kurbo::PathEl::MoveTo(p) => pb.move_to(f(p.x), f(p.y)),
kurbo::PathEl::LineTo(p) => pb.line_to(f(p.x), f(p.y)),
kurbo::PathEl::QuadTo(a, p) => pb.quad_to(f(a.x), f(a.y), f(p.x), f(p.y)),
kurbo::PathEl::CurveTo(a, b, p) => pb.cubic_to(f(a.x), f(a.y), f(b.x), f(b.y), f(p.x), f(p.y)),
kurbo::PathEl::ClosePath => pb.close(),
}
}
}
fn clamp_side(v: f32) -> f32 {
@@ -341,7 +406,10 @@ struct Exporter<'a> {
opts: &'a PdfOptions,
/// Layout page index → output page index.
out_index: HashMap<usize, usize>,
fonts: HashMap<u32, Option<Font>>,
/// Embedded fonts by face id (`None`: krilla can't read it).
fonts: HashMap<u32, (FaceRef, Option<Font>)>,
/// Faces whose glyphs are drawn as outlines because krilla can't subset them.
outlined: &'a HashSet<u32>,
cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
images: HashMap<String, Option<Image>>,
tags: Vec<TagEntry>,
@@ -363,17 +431,22 @@ enum Role {
impl Exporter<'_> {
fn font(&mut self, face: &FaceRef) -> Option<Font> {
if self.outlined.contains(&face.id()) {
return None;
}
self.fonts
.entry(face.id())
.or_insert_with(|| {
let data: krilla::Data = face.data().to_vec().into();
if face.is_variable() {
let font = if face.is_variable() {
let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
Font::new_variable(data, face.index(), &coords)
} else {
Font::new(data, face.index())
}
};
(*face, font)
})
.1
.clone()
}
@@ -745,10 +818,11 @@ impl Exporter<'_> {
if !glyphs.iter().all(|(_, x, y)| ok(*x) && ok(*y)) {
return;
}
let Some(font) = self.font(face) else {
let font = self.font(face);
if font.is_none() && !self.outlined.contains(&face.id()) {
log::warn!("PDF: font {} {} could not be embedded", face.family, face.style);
return;
};
}
let (txt, ranges) = self.map_text(face, glyphs, text);
let upem = face.upem.max(1.0) as f32;
let kg: Vec<KrillaGlyph> = glyphs
@@ -773,7 +847,16 @@ impl Exporter<'_> {
} else {
None
});
s.draw_glyphs(Point::from_xy(x0, y0), &kg, font, &txt, size, false);
match font {
Some(font) => {
s.draw_glyphs(Point::from_xy(x0, y0), &kg, font, &txt, size, false);
}
None => {
if let Some(path) = glyph_outlines(face, size, glyphs) {
s.draw_path(&path);
}
}
}
s.set_fill(None);
s.set_stroke(None);
if pushed {
@@ -912,7 +995,10 @@ impl Exporter<'_> {
let (c, sn) = (angle.cos(), angle.sin());
let (dx, dy) = (-raw_w * size / 2.0, size * 0.35);
let (cx, cy) = (w / 2.0, hh / 2.0);
let Some(font) = self.font(&face) else { return };
let font = self.font(&face);
if font.is_none() && !self.outlined.contains(&face.id()) {
return;
}
let glyphs: Vec<KrillaGlyph> = shaped
.iter()
.enumerate()
@@ -933,7 +1019,23 @@ impl Exporter<'_> {
s.push_transform(&Transform::from_row(c, sn, -sn, c, c * dx - sn * dy + cx, sn * dx + c * dy + cy));
s.set_stroke(None);
s.set_fill(Some(fill(wm.color, if wm.semitransparent { 0.5 } else { 1.0 })));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, size, false);
match font {
Some(font) => {
s.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, &text, size, false);
}
None => {
let k = size / upem;
let mut x = 0.0;
let mut pen = Vec::with_capacity(shaped.len());
for g in &shaped {
pen.push((g.gid, x + g.x_offset as f32 * k, -(g.y_offset as f32) * k));
x += g.x_advance as f32 * k;
}
if let Some(path) = glyph_outlines(&face, size, &pen) {
s.draw_path(&path);
}
}
}
s.set_fill(None);
s.pop();
}
+99
View File
@@ -233,3 +233,102 @@ fn shapes_have_paths() {
assert_eq!(parse_iso("garbage"), None);
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
}
/// Bundled Source Sans 3 renamed to `family`, with glyph `gid` turned into a composite glyph whose
/// component list is cut off: fonts still parse and shape, but krilla's subsetter rejects it.
fn unsubsettable_font(family: &str, gid: u16) -> Vec<u8> {
let mut f = wordcraft_fonts::bundled()[0].to_vec();
let rename = |f: &mut Vec<u8>, from: &[u8], to: &[u8]| {
let mut i = 0;
while i + from.len() <= f.len() {
if &f[i..i + from.len()] == from {
f[i..i + from.len()].copy_from_slice(to);
}
i += 1;
}
};
let wide = |s: &str| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect::<Vec<u8>>();
assert_eq!(family.len(), "Source Sans 3".len());
rename(&mut f, &wide("Source Sans 3"), &wide(family));
rename(&mut f, b"Source Sans 3", family.as_bytes());
let u16_at = |f: &[u8], o: usize| u16::from_be_bytes([f[o], f[o + 1]]);
let u32_at = |f: &[u8], o: usize| u32::from_be_bytes([f[o], f[o + 1], f[o + 2], f[o + 3]]);
let table = |f: &[u8], tag: &[u8]| {
(0..u16_at(f, 4) as usize).map(|t| 12 + t * 16).find(|r| &f[*r..*r + 4] == tag).map(|r| u32_at(f, r + 8) as usize).unwrap()
};
let (head, loca, glyf) = (table(&f, b"head"), table(&f, b"loca"), table(&f, b"glyf"));
let g = gid as usize;
let off = if u16_at(&f, head + 50) == 0 {
// Short offsets (halved): the glyph keeps only its 10-byte header.
let start = u16_at(&f, loca + g * 2);
f[loca + g * 2 + 2..loca + g * 2 + 4].copy_from_slice(&(start + 5).to_be_bytes());
start as usize * 2
} else {
let start = u32_at(&f, loca + g * 4);
f[loca + g * 4 + 4..loca + g * 4 + 8].copy_from_slice(&(start + 10).to_be_bytes());
start as usize
};
// numberOfContours = -1: a composite glyph, with no room left for its components.
f[glyf + off..glyf + off + 2].copy_from_slice(&(-1i16).to_be_bytes());
f
}
/// Glyphs drawn as text and paths drawn, over every page.
fn count_glyphs_and_paths(bytes: &[u8]) -> (usize, usize) {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
use hayro_syntax::Pdf;
struct Count(usize, usize);
impl Device<'_> for Count {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {
self.1 += 1;
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
self.0 += 1;
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
let mut n = Count(0, 0);
for page in pdf.pages().iter() {
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
interpret_page(page, &mut ctx, &mut n);
}
(n.0, n.1)
}
#[test]
fn unsubsettable_font_is_drawn_as_outlines() {
let db = wordcraft_fonts::FontDb::global();
let o = db.face("Source Sans 3", "Regular").glyph_for('o');
let family = "Broken Sans 3";
db.add_font(unsubsettable_font(family, u16::try_from(o).unwrap()));
assert!(db.has_family(family));
// Leave out the glyph after the broken one: its start moved into the broken glyph's data.
let text: String = "Broken font, good export".chars().filter(|c| db.face(family, "Regular").glyph_for(*c) != o + 1).collect();
let doc = |font: &str| {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text(&text, CharProps { font: Some(font.into()), ..Default::default() }))];
d
};
let opts = PdfOptions { tagged: false, ..Default::default() };
// The intact font is embedded as text.
let (glyphs, _) = count_glyphs_and_paths(&export(&doc("Source Sans 3"), &opts).unwrap());
assert!(glyphs > 0);
// The broken one used to fail the whole export ("failed to subset font: malformed font");
// now its text is drawn as outlines.
let bytes = export(&doc(family), &opts).unwrap();
let (glyphs, paths) = count_glyphs_and_paths(&bytes);
assert_eq!(glyphs, 0, "no text in the font krilla can't subset");
assert!(paths > 0, "its glyphs are drawn as outlines");
}