mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 12:57:09 -06:00
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:
co-authored by
Oliver Zieschang
Claude Opus 5.5
parent
cde88df944
commit
fbb688657d
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user