Files
wordcraft/crates/pdf/src/lib.rs
T
fbb688657d 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>
2026-10-10 06:17:11 -04:00

1061 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft PDF export, written with `krilla`.
//!
//! [`export`] lays the document out and draws each page's display list
//! ([`wordcraft_layout::display::page_display`]) into a PDF page:
//!
//! - **Text as real text:** glyph runs are written with embedded, subsetted fonts and a Unicode
//! mapping taken from the paragraph text, so text is selectable, searchable and extractable.
//! Synthetic bold (fill + stroke) and italic (skew) follow the renderer.
//! - **Graphics:** fills (highlight, shading, cell fills), rules and underlines (solid, dotted,
//! dashed, double, wave), shapes, pictures (decoded with `image`; JPEG passed through, the rest
//! embedded losslessly) with cropping and opacity, the page colour and the watermark.
//! - **Interactive:** link annotations for hyperlinked runs (URLs and internal `#bookmark`
//! links) and a document outline built from the headings (outline levels).
//! - **Metadata:** title, author, subject, keywords, language, creation date.
//! - **Tagged PDF (best effort):** one structure element per paragraph (`H1`–`H6` for headings,
//! `P` otherwise), `Figure` for pictures with their alt text; headers, footers, decorations and
//! the watermark are artifacts.
//!
//! Nothing here panics on hostile documents: sizes are clamped, unknown fonts or undecodable
//! pictures are skipped (a grey box stands in for a picture).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::{HashMap, HashSet};
use std::num::NonZeroU16;
use std::sync::Arc;
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};
use krilla::num::NormalizedF32;
use krilla::outline::{Outline, OutlineNode};
use krilla::page::PageSettings;
use krilla::paint::{Fill, FillRule, LineCap, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree};
use krilla::text::{Font, GlyphId, KrillaGlyph};
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, Page, Placed, layout};
/// Largest page side we write (200", the PDF limit).
const MAX_SIDE: f32 = 14_400.0;
/// Largest picture side we embed (pixels).
const MAX_IMAGE_SIDE: u32 = 30_000;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum PdfError {
#[error("the document has no pages to export")]
NoPages,
#[error("page {0} does not exist")]
BadPage(usize),
#[error("PDF writing failed: {0}")]
Write(String),
}
/// Export options.
#[derive(Clone, Debug)]
pub struct PdfOptions {
/// Pages to export (0-based layout page indexes, in output order); `None` = all.
pub pages: Option<Vec<usize>>,
/// Title / author overrides (default: the document properties).
pub title: Option<String>,
pub author: Option<String>,
/// Write a structure tree (tagged PDF, best effort).
pub tagged: bool,
/// Show tracked changes as markup (coloured, underlined / struck through).
pub include_markup: bool,
/// Compress content streams.
pub compress: bool,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions { pages: None, title: None, author: None, tagged: true, include_markup: false, compress: true }
}
}
/// Lay out `doc` and write it as PDF.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let lay = layout(doc, &mut LayoutCache::new(), &LayoutOptions::default());
export_layout(doc, &lay, opts)
}
/// Write an existing layout of `doc` as PDF.
pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let pages: Vec<usize> = match &opts.pages {
Some(v) => {
if let Some(bad) = v.iter().find(|p| **p >= lay.pages.len()) {
return Err(PdfError::BadPage(*bad + 1));
}
v.clone()
}
None => (0..lay.pages.len()).collect(),
};
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));
let mut ex = Exporter {
doc,
lay,
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) {
pdf.set_outline(o);
}
for (out, &pi) in pages.iter().enumerate() {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi + 1)) };
let (w, h) = (clamp_side(page.w), clamp_side(page.h));
let size = Size::from_wh(w, h).ok_or(PdfError::NoPages)?;
let mut kp = pdf.start_page_with(PageSettings::new(size));
let mut s = kp.surface();
ex.links.clear();
ex.page(&mut s, page, w, h);
s.finish();
for a in ex.annotations(out) {
kp.add_annotation(a);
}
kp.finish();
}
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
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 {
if v.is_finite() { v.clamp(3.0, MAX_SIDE) } else { 612.0 }
}
fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
let c = &doc.core;
let mut m = Metadata::new().creator("WordCraft".into()).producer("WordCraft".into());
let title = opts.title.clone().unwrap_or_else(|| c.title.clone());
if !title.trim().is_empty() {
m = m.title(title);
}
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
if !author.trim().is_empty() {
m = m.authors(vec![author]);
}
if !c.subject.trim().is_empty() {
m = m.description(c.subject.clone());
} else if !c.description.trim().is_empty() {
m = m.description(c.description.clone());
}
let kw: Vec<String> = c.keywords.split([',', ';']).map(|k| k.trim().to_string()).filter(|k| !k.is_empty()).collect();
if !kw.is_empty() {
m = m.keywords(kw);
}
if let Some(lang) = doc.styles.default_chr.lang.clone().filter(|l| !l.is_empty() && l.is_ascii()) {
m = m.language(lang);
}
let date = parse_iso(&c.created).or_else(|| now_unix().map(civil));
if let Some((y, mo, d, h, mi, s)) = date {
m = m.creation_date(
DateTime::new(y.clamp(0, 9999) as u16).month(mo).day(d).hour(h).minute(mi).second(s).utc_offset_hour(0).utc_offset_minute(0),
);
}
m
}
type Civil = (i64, u8, u8, u8, u8, u8);
/// `YYYY-MM-DD[THH:MM[:SS]]…` → civil time.
fn parse_iso(s: &str) -> Option<Civil> {
let n = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<i64>().ok());
let (y, mo, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
return None;
}
let h = n(11, 13).unwrap_or(0).clamp(0, 23);
let mi = n(14, 16).unwrap_or(0).clamp(0, 59);
let sec = n(17, 19).unwrap_or(0).clamp(0, 59);
Some((y, mo as u8, d as u8, h as u8, mi as u8, sec as u8))
}
#[cfg(not(target_arch = "wasm32"))]
fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
}
#[cfg(target_arch = "wasm32")]
fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → civil time (proleptic Gregorian).
fn civil(t: i64) -> Civil {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
}
fn norm(v: f32) -> NormalizedF32 {
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
}
fn fill(c: Rgb, alpha: f32) -> Fill {
Fill { paint: rgb::Color::new(c.0, c.1, c.2).into(), opacity: norm(alpha), rule: FillRule::NonZero }
}
fn ok(v: f32) -> bool {
v.is_finite() && v.abs() < 1e6
}
fn rect_path(r: &Rect) -> Option<Path> {
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
return None;
}
let mut pb = PathBuilder::new();
pb.push_rect(krilla::geom::Rect::from_xywh(r.x, r.y, r.w, r.h)?);
pb.finish()
}
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
return None;
}
let (x0, y0, x1, y1) = (r.x, r.y, r.x + r.w, r.y + r.h);
let (cx, cy) = (x0 + r.w / 2.0, y0 + r.h / 2.0);
let mut pb = PathBuilder::new();
let poly = |pb: &mut PathBuilder, pts: &[(f32, f32)]| {
for (i, (x, y)) in pts.iter().enumerate() {
if i == 0 {
pb.move_to(*x, *y);
} else {
pb.line_to(*x, *y);
}
}
pb.close();
};
const K: f32 = 0.552_284_8;
let ellipse = |pb: &mut PathBuilder, cx: f32, cy: f32, rx: f32, ry: f32| {
pb.move_to(cx + rx, cy);
pb.cubic_to(cx + rx, cy + ry * K, cx + rx * K, cy + ry, cx, cy + ry);
pb.cubic_to(cx - rx * K, cy + ry, cx - rx, cy + ry * K, cx - rx, cy);
pb.cubic_to(cx - rx, cy - ry * K, cx - rx * K, cy - ry, cx, cy - ry);
pb.cubic_to(cx + rx * K, cy - ry, cx + rx, cy - ry * K, cx + rx, cy);
pb.close();
};
match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]),
ShapeKind::RoundedRectangle => {
let rad = r.w.min(r.h) * 0.16;
let k = rad * (1.0 - K);
pb.move_to(x0 + rad, y0);
pb.line_to(x1 - rad, y0);
pb.cubic_to(x1 - k, y0, x1, y0 + k, x1, y0 + rad);
pb.line_to(x1, y1 - rad);
pb.cubic_to(x1, y1 - k, x1 - k, y1, x1 - rad, y1);
pb.line_to(x0 + rad, y1);
pb.cubic_to(x0 + k, y1, x0, y1 - k, x0, y1 - rad);
pb.line_to(x0, y0 + rad);
pb.cubic_to(x0, y0 + k, x0 + k, y0, x0 + rad, y0);
pb.close();
}
ShapeKind::Ellipse => ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0),
ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]),
ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]),
ShapeKind::Line => {
pb.move_to(x0, y1);
pb.line_to(x1, y0);
}
ShapeKind::Arrow => {
let h = r.h;
poly(
&mut pb,
&[
(x0, cy - h * 0.2),
(x1 - h * 0.5, cy - h * 0.2),
(x1 - h * 0.5, y0),
(x1, cy),
(x1 - h * 0.5, y1),
(x1 - h * 0.5, cy + h * 0.2),
(x0, cy + h * 0.2),
],
)
}
ShapeKind::Star => {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = std::f32::consts::PI * i as f32 / 5.0 - std::f32::consts::FRAC_PI_2;
let rad = if i % 2 == 0 { 1.0 } else { 0.4 };
(cx + a.cos() * r.w / 2.0 * rad, cy + a.sin() * r.h / 2.0 * rad)
})
.collect();
poly(&mut pb, &pts);
}
ShapeKind::Heart => {
let (w, h) = (r.w, r.h);
pb.move_to(cx, y0 + h * 0.3);
pb.cubic_to(cx, y0, x0, y0, x0, y0 + h * 0.3);
pb.cubic_to(x0, y0 + h * 0.6, cx - w * 0.1, y0 + h * 0.75, cx, y1);
pb.cubic_to(cx + w * 0.1, y0 + h * 0.75, x1, y0 + h * 0.6, x1, y0 + h * 0.3);
pb.cubic_to(x1, y0, cx, y0, cx, y0 + h * 0.3);
pb.close();
}
}
pb.finish()
}
/// A structure element being collected (one per paragraph, in reading order).
struct TagEntry {
kind: TagKind,
children: Vec<Node>,
}
struct Exporter<'a> {
doc: &'a Document,
lay: &'a DocLayout,
opts: &'a PdfOptions,
/// Layout page index → output page index.
out_index: HashMap<usize, usize>,
/// 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>,
tag_index: HashMap<(StoryRef, DocPath), usize>,
/// Link rectangles on the current page: (x0, y0, x1, y1, target).
links: Vec<(f32, f32, f32, f32, String)>,
}
/// What the content being drawn is, for tagging.
#[derive(Clone, Copy, PartialEq)]
enum Role {
/// Paragraph content of the structure entry at this index.
Para(usize),
/// Header/footer, decorations: not part of the structure.
Artifact(ArtifactType),
/// A floating picture (its own Figure).
Figure,
}
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();
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()
}
fn cmap(&mut self, face: &FaceRef) -> Arc<HashMap<u32, char>> {
self.cmaps
.entry(face.id())
.or_insert_with(|| {
let mut m = HashMap::new();
for (c, g) in face.chars() {
m.entry(g).or_insert(c);
}
Arc::new(m)
})
.clone()
}
fn image(&mut self, key: &str) -> Option<Image> {
if let Some(i) = self.images.get(key) {
return i.clone();
}
let img = self.doc.media.get(key).and_then(load_image);
if img.is_none() {
log::warn!("PDF: picture `{key}` could not be decoded");
}
self.images.insert(key.to_string(), img.clone());
img
}
/// Run `f` inside a marked-content section for `role`.
fn tagged(&mut self, s: &mut Surface, role: Role, alt: Option<&str>, f: impl FnOnce(&mut Self, &mut Surface)) {
if !self.opts.tagged {
f(self, s);
return;
}
match role {
Role::Artifact(kind) => {
s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None)));
f(self, s);
s.end_tagged();
}
Role::Para(i) => {
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
f(self, s);
s.end_tagged();
if let Some(e) = self.tags.get_mut(i) {
e.children.push(Node::Leaf(id));
}
}
Role::Figure => {
let id: Identifier = s.start_tagged(ContentTag::Other);
f(self, s);
s.end_tagged();
let alt = alt.map(str::to_string).filter(|a| !a.is_empty()).or_else(|| Some("picture".to_string()));
let g = TagGroup::with_children(Tag::Figure(alt), vec![Node::Leaf(id)]);
self.tags.push(TagEntry { kind: Tag::Div.into(), children: vec![Node::Group(g)] });
}
}
}
fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) {
if let Some(c) = self.doc.settings.page_color
&& let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h))
{
self.tagged(s, Role::Artifact(ArtifactType::Other), None, |_, s| {
s.set_stroke(None);
s.set_fill(Some(fill(c, 1.0)));
s.draw_path(&p);
s.set_fill(None);
});
}
if let Some(wm) = self.doc.settings.watermark.clone()
&& !wm.text.trim().is_empty()
{
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h));
}
let dopts = DisplayOptions { marks: false, dim_header: false, dim_body: false, markup: self.opts.include_markup };
for it in page.header.iter().chain(page.footer.iter()) {
let draws = self.draws(page, it, &dopts);
self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| {
for d in &draws {
me.draw(s, d);
}
});
}
for it in &page.items {
let draws = self.draws(page, it, &dopts);
match it {
Placed::Lines { story, path, .. } => {
let idx = self.entry(*story, path);
for d in &draws {
match d {
Draw::Glyphs { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
Draw::Image { .. } => self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
}
}
Placed::Image { story, path, off, .. } => {
let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) {
Some(InlineObject::Image { alt, .. }) => alt.clone(),
_ => String::new(),
};
self.tagged(s, Role::Figure, Some(&alt), |me, s| {
for d in &draws {
me.draw(s, d);
}
});
}
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| {
for d in &draws {
me.draw(s, d);
}
}),
}
}
}
/// Display items for one placed item.
fn draws(&self, page: &Page, it: &Placed, dopts: &DisplayOptions) -> Vec<Draw> {
let one =
Page { w: page.w, h: page.h, section: page.section, number: page.number, items: vec![it.clone()], body: page.body, ..Default::default() };
page_display(self.doc, &one, dopts)
}
/// The structure entry for a paragraph (created on first sight, in reading order).
fn entry(&mut self, story: StoryRef, path: &DocPath) -> usize {
if let Some(i) = self.tag_index.get(&(story, path.clone())) {
return *i;
}
let kind: TagKind = match self.doc.para(story, path).map(|p| self.doc.styles.resolve_para(&p.props).outline_level) {
Some(Some(l)) if l < 6 => {
let title = self.doc.para(story, path).map(|p| p.plain_text().trim().chars().take(200).collect::<String>());
Tag::Hn(NonZeroU16::new(u16::from(l) + 1).unwrap_or(NonZeroU16::MIN), title).into()
}
_ => Tag::P.into(),
};
self.tags.push(TagEntry { kind, children: Vec::new() });
let i = self.tags.len() - 1;
self.tag_index.insert((story, path.clone()), i);
i
}
fn tag_tree(&mut self) -> TagTree {
let mut tree = TagTree::new().with_lang(self.doc.styles.default_chr.lang.clone());
for e in std::mem::take(&mut self.tags) {
if e.children.is_empty() {
continue;
}
tree.push(TagGroup::with_children(e.kind, e.children));
}
tree
}
fn draw(&mut self, s: &mut Surface, d: &Draw) {
match d {
Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link } => {
self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text);
if let Some(l) = link {
self.link_rect(face, *size, glyphs, l);
}
}
Draw::Fill { rect, color, alpha } => {
if let Some(p) = rect_path(rect) {
s.set_stroke(None);
s.set_fill(Some(fill(*color, *alpha)));
s.draw_path(&p);
s.set_fill(None);
}
}
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha),
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
let Some(p) = shape_path(*kind, rect) else { return };
let can_fill = *kind != ShapeKind::Line;
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke {
paint: rgb::Color::new(c.0, c.1, c.2).into(),
width: if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
..Default::default()
}));
if f.is_some() && can_fill || stroke.is_some() {
s.draw_path(&p);
}
s.set_fill(None);
s.set_stroke(None);
}
Draw::Mark { .. } => {}
}
}
fn line(&mut self, s: &mut Surface, (x0, y0, x1, y1): (f32, f32, f32, f32), width: f32, c: Rgb, style: LineStyle, alpha: f32) {
if ![x0, y0, x1, y1].iter().all(|v| ok(*v)) {
return;
}
let w = if width.is_finite() { width.clamp(0.1, 100.0) } else { 0.5 };
let paint: krilla::paint::Paint = rgb::Color::new(c.0, c.1, c.2).into();
let stroke = |width: f32, dash: Option<StrokeDash>, cap: LineCap| Stroke {
paint: paint.clone(),
width,
opacity: norm(alpha),
dash,
line_cap: cap,
..Default::default()
};
let seg = |s: &mut Surface, dx: f32, dy: f32| {
let mut pb = PathBuilder::new();
pb.move_to(x0 + dx, y0 + dy);
pb.line_to(x1 + dx, y1 + dy);
if let Some(p) = pb.finish() {
s.draw_path(&p);
}
};
s.set_fill(None);
match style {
LineStyle::Solid => {
s.set_stroke(Some(stroke(w, None, LineCap::Butt)));
seg(s, 0.0, 0.0);
}
LineStyle::Dotted => {
s.set_stroke(Some(stroke(w, Some(StrokeDash { array: vec![w, w * 2.0], offset: 0.0 }), LineCap::Butt)));
seg(s, 0.0, 0.0);
}
LineStyle::Dashed => {
s.set_stroke(Some(stroke(w, Some(StrokeDash { array: vec![w * 4.0, w * 2.0], offset: 0.0 }), LineCap::Butt)));
seg(s, 0.0, 0.0);
}
LineStyle::Double => {
// Two thinner lines either side of the centre line.
let (dx, dy) = (x1 - x0, y1 - y0);
let len = (dx * dx + dy * dy).sqrt().max(1e-3);
let (nx, ny) = (-dy / len * w * 0.9, dx / len * w * 0.9);
s.set_stroke(Some(stroke(w * 0.6, None, LineCap::Butt)));
seg(s, nx, ny);
seg(s, -nx, -ny);
}
LineStyle::Wave => {
let (a, b) = (x0.min(x1), x0.max(x1));
let amp = (w * 1.2).max(0.8);
let step = amp * 2.0;
let mut pb = PathBuilder::new();
pb.move_to(a, y0);
let mut x = a;
let mut up = true;
while x < b && x - a < 20_000.0 {
let nx = (x + step).min(b);
pb.quad_to((x + nx) / 2.0, if up { y0 - amp } else { y0 + amp }, nx, y0);
x = nx;
up = !up;
}
s.set_stroke(Some(stroke(w * 0.8, None, LineCap::Round)));
if let Some(p) = pb.finish() {
s.draw_path(&p);
}
}
}
s.set_stroke(None);
}
fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) {
let Some(clip) = rect_path(rect) else { return };
let Some(img) = self.image(media) else {
s.set_stroke(None);
s.set_fill(Some(fill(Rgb(0xD0, 0xD0, 0xD0), 1.0)));
s.draw_path(&clip);
s.set_fill(None);
return;
};
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) } else { 0.0 });
let vis_w = (1.0 - cl - cr).max(0.05);
let vis_h = (1.0 - ct - cb).max(0.05);
let (fw, fh) = (rect.w / vis_w, rect.h / vis_h);
let Some(size) = Size::from_wh(fw.max(0.01), fh.max(0.01)) else { return };
let fade = alpha < 0.999;
if fade {
s.push_opacity(norm(alpha));
}
s.push_clip_path(&clip, &FillRule::NonZero);
s.push_transform(&Transform::from_translate(rect.x - cl * fw, rect.y - ct * fh));
s.draw_image(img, size);
s.pop();
s.pop();
if fade {
s.pop();
}
}
/// Unicode text for each glyph: (text, per-glyph byte range). Glyphs and the run's text are
/// aligned through the font's reverse character map (ligatures take several characters,
/// extra glyphs share their cluster's text).
fn map_text(&mut self, face: &FaceRef, glyphs: &[(u32, f32, f32)], text: &str) -> (String, Vec<std::ops::Range<usize>>) {
let cmap = self.cmap(face);
let rev = |g: u32| cmap.get(&g).copied();
if text.is_empty() {
let mut t = String::new();
let ranges = glyphs
.iter()
.map(|(g, _, _)| {
let a = t.len();
t.push(rev(*g).unwrap_or(' '));
a..t.len()
})
.collect();
return (t, ranges);
}
let chars: Vec<(usize, char)> = text.char_indices().collect();
let n = chars.len();
let byte = |i: usize| chars.get(i).map(|c| c.0).unwrap_or(text.len());
let mut ranges = Vec::with_capacity(glyphs.len());
let mut i = 0usize;
for (k, (g, _, _)) in glyphs.iter().enumerate() {
let left_g = glyphs.len() - k;
let left_c = n.saturating_sub(i);
if left_c == 0 {
let r = ranges.last().cloned().unwrap_or(0..text.len());
ranges.push(r);
continue;
}
if k + 1 == glyphs.len() {
ranges.push(byte(i)..text.len());
i = n;
continue;
}
let this = chars.get(i).map(|c| c.1);
let take = if left_c <= left_g || rev(*g) == this {
if left_g > left_c && rev(*g) != this && k > 0 {
// More glyphs than characters: this one shares the previous cluster.
let r = ranges.last().cloned().unwrap_or(byte(i)..byte(i + 1));
ranges.push(r);
continue;
}
1
} else {
// A ligature (or substituted glyph): consume until the next glyph's character.
let next = glyphs.get(k + 1).and_then(|(ng, _, _)| rev(*ng));
let max = left_c - (left_g - 1);
let mut t = 1;
while t < max && chars.get(i + t).map(|c| c.1) != next {
t += 1;
}
if t == max && next.is_some() && chars.get(i + t).map(|c| c.1) != next {
// The next glyph doesn't match anything: assume one character each.
t = 1;
}
t
};
ranges.push(byte(i)..byte(i + take));
i += take;
}
(text.to_string(), ranges)
}
#[allow(clippy::too_many_arguments)]
fn glyphs(
&mut self,
s: &mut Surface,
face: &FaceRef,
size: f32,
glyphs: &[(u32, f32, f32)],
color: Rgb,
alpha: f32,
bold: bool,
italic: bool,
text: &str,
) {
if !size.is_finite() || size <= 0.0 || size > 5000.0 {
return;
}
let Some(&(_, x0, y0)) = glyphs.first() else { return };
if !glyphs.iter().all(|(_, x, y)| ok(*x) && ok(*y)) {
return;
}
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
.iter()
.zip(ranges)
.enumerate()
.map(|(k, ((g, x, y), r))| {
let adv = match glyphs.get(k + 1) {
Some((_, nx, _)) => (nx - x) / size,
None => face.advance(*g) as f32 / upem,
};
KrillaGlyph::new(GlyphId::new(*g), adv, 0.0, (y0 - y) / size, 0.0, r, None)
})
.collect();
let pushed = italic;
if italic {
s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * y0, 0.0));
}
s.set_fill(Some(fill(color, alpha)));
s.set_stroke(if bold {
Some(Stroke { paint: rgb::Color::new(color.0, color.1, color.2).into(), width: size * 0.03, opacity: norm(alpha), ..Default::default() })
} else {
None
});
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 {
s.pop();
}
}
fn link_rect(&mut self, face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)], link: &str) {
let (Some(first), Some(last)) = (glyphs.first(), glyphs.last()) else { return };
let adv = face.advance(last.0) as f32 * size / face.upem.max(1.0) as f32;
let (x0, x1) = (first.1.min(last.1), first.1.max(last.1) + adv);
let (y0, y1) = (first.2 - size * 0.85, first.2 + size * 0.25);
if ![x0, x1, y0, y1].iter().all(|v| ok(*v)) {
return;
}
if let Some(prev) = self.links.last_mut()
&& prev.4 == link
&& (prev.1 - y0).abs() < 0.5
&& x0 - prev.2 < size
{
prev.0 = prev.0.min(x0);
prev.2 = prev.2.max(x1);
return;
}
if self.links.len() < 10_000 {
self.links.push((x0, y0, x1, y1, link.to_string()));
}
}
/// Where a bookmark is: (output page, x, y).
fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> {
let (_, pos) = self.doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
self.locate(pos.story, &pos.path)
}
/// The first output page showing a paragraph, with the top of its first line.
fn locate(&self, story: StoryRef, path: &DocPath) -> Option<(usize, f32, f32)> {
let places = self.lay.index.get(&(story, path.clone()))?;
for (pi, ii) in places {
let Some(out) = self.out_index.get(pi) else { continue };
let (x, y) = match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) {
Some(Placed::Lines { x, y, .. }) => (*x, *y),
_ => (0.0, 0.0),
};
return Some((*out, x, y));
}
None
}
fn annotations(&self, _out: usize) -> Vec<Annotation> {
let mut v = Vec::new();
for (x0, y0, x1, y1, target) in &self.links {
let Some(rect) = krilla::geom::Rect::from_ltrb(*x0, *y0, *x1, *y1) else { continue };
let t = if let Some(name) = target.strip_prefix('#') {
match self.bookmark(name) {
Some((page, x, y)) => Target::Destination(XyzDestination::new(page, Point::from_xy(x, y)).into()),
None => continue,
}
} else {
let url = target.trim();
if url.is_empty() {
continue;
}
Target::Action(Action::Link(LinkAction::new(url.to_string())))
};
v.push(Annotation::new_link(LinkAnnotation::new(rect, t), Some(target.clone())));
}
v
}
/// The outline from body headings (outline levels 1–9) on the exported pages.
fn outline(&self, pages: &[usize]) -> Option<Outline> {
let mut flat: Vec<(u8, String, usize, f32, f32)> = Vec::new();
for (out, pi) in pages.iter().enumerate() {
let Some(page) = self.lay.pages.get(*pi) else { continue };
for it in &page.items {
let Placed::Lines { story: StoryRef::Body, path, l0: 0, x, y, .. } = it else { continue };
let Some(p) = self.doc.para(StoryRef::Body, path) else { continue };
let Some(level) = self.doc.styles.resolve_para(&p.props).outline_level else { continue };
let title: String = p.plain_text().split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect();
if title.is_empty() {
continue;
}
flat.push((level.min(8), title, out, *x, *y));
}
}
if flat.is_empty() {
return None;
}
fn build(flat: &[(u8, String, usize, f32, f32)], i: &mut usize, level: u8) -> Vec<OutlineNode> {
let mut out = Vec::new();
while let Some((l, title, page, x, y)) = flat.get(*i) {
if *l < level {
break;
}
*i += 1;
let mut node = OutlineNode::new(title.clone(), XyzDestination::new(*page, Point::from_xy(*x, *y)));
// Children: following entries with a deeper level.
if flat.get(*i).is_some_and(|n| n.0 > *l) {
for c in build(flat, i, l + 1) {
node.push_child(c);
}
}
out.push(node);
}
out
}
let mut o = Outline::new();
let mut i = 0;
while i < flat.len() {
let before = i;
for n in build(&flat, &mut i, 0) {
o.push_child(n);
}
if i == before {
i += 1;
}
}
Some(o)
}
fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;
let text: String = wm.text.chars().take(200).collect();
let shaped = wordcraft_fonts::shape(&face, &text, &[], |c| c);
let upem = face.upem.max(1.0) as f32;
let raw_w: f32 = shaped.iter().map(|g| g.x_advance as f32).sum::<f32>() / upem;
if raw_w <= 0.0 || !raw_w.is_finite() {
return;
}
let hh = h.min(2000.0);
let diag = if wm.diagonal { (w * w + hh * hh).sqrt() } else { w };
let size = (diag * 0.7 / raw_w).min(300.0);
let angle = if wm.diagonal { -(hh / w.max(1.0)).atan() } else { 0.0 };
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 font = self.font(&face);
if font.is_none() && !self.outlined.contains(&face.id()) {
return;
}
let glyphs: Vec<KrillaGlyph> = shaped
.iter()
.enumerate()
.map(|(i, g)| {
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(text.len());
let start = g.cluster.min(end);
KrillaGlyph::new(
GlyphId::new(g.gid),
g.x_advance as f32 / upem,
g.x_offset as f32 / upem,
g.y_offset as f32 / upem,
0.0,
start..end,
None,
)
})
.collect();
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 })));
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();
}
}
/// Decode a picture for embedding (validated with the `image` crate first, so a broken file
/// can't fail the whole export).
fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
let fmt = image::guess_format(data).ok()?;
let dec = image::load_from_memory_with_format(data, fmt).ok()?;
let (w, h) = (dec.width(), dec.height());
if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE {
return None;
}
if fmt == image::ImageFormat::Jpeg && matches!(dec.color(), image::ColorType::L8 | image::ColorType::Rgb8) {
return Image::from_jpeg(data.clone().into(), true).ok();
}
Some(Image::from_rgba8(dec.to_rgba8().into_raw(), w, h))
}
#[cfg(test)]
mod tests;