diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 7f55d1a85..b64c6c526 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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 } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b749097a6..b04d74a57 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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), + /// 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) -> Result { 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 { + 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, - fonts: HashMap>, + /// Embedded fonts by face id (`None`: krilla can't read it). + fonts: HashMap)>, + /// Faces whose glyphs are drawn as outlines because krilla can't subset them. + outlined: &'a HashSet, cmaps: HashMap>>, images: HashMap>, tags: Vec, @@ -363,17 +431,22 @@ enum Role { impl Exporter<'_> { fn font(&mut self, face: &FaceRef) -> Option { + 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 = 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 = 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(); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 533294539..75698a9ba 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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 { + let mut f = wordcraft_fonts::bundled()[0].to_vec(); + let rename = |f: &mut Vec, 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::>(); + 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>) {} + 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>, _: 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"); +}