Files
wordcraft/crates/pdf/src/lib.rs
T

1932 lines
84 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 through the shared `craft-pdf` writer and Krilla drawing primitives.
//!
//! [`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; WMF and EMF drawn as vector paths and embedded bitmaps) 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 craft_pdf::write::{self, Metadata, WriteError, Writer, backend as krilla};
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;
use krilla::num::NormalizedF32;
use krilla::outline::{Outline, OutlineNode};
use krilla::page::PageSettings;
use krilla::paint::{Fill, FillRule, LineCap, LineJoin, 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::graphic::GraphicKind;
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::Paint;
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display};
use wordcraft_layout::para::{ClKind, LineEnd};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, Page, Placed, layout};
use wordcraft_metafile::{Picture, PlacedItem};
/// 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),
}
impl From<WriteError> for PdfError {
fn from(error: WriteError) -> Self {
Self::Write(error.to_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,
/// Print Markup's comments: each page is scaled down to make room on its right for the
/// comments in balloons joined to their anchors (Word's "Document showing markup").
pub comments: bool,
/// Compress content streams.
pub compress: bool,
/// Print on this paper (width, height in points) instead of each page's own size: the page
/// is scaled to fit and centred, the paper turned to the page's orientation (Scale to paper
/// size, #2108). `None` keeps the document's page sizes.
pub paper: Option<(f32, f32)>,
/// How [`export`] shows digits (the Numeral setting; callers resolve `System`, see
/// [`NumeralMode::resolve`]). [`export_layout`] keeps the layout it is given.
pub numeral: NumeralMode,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions {
pages: None,
title: None,
author: None,
tagged: true,
include_markup: false,
comments: false,
compress: true,
paper: None,
numeral: NumeralMode::Arabic,
}
}
}
/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked
/// deletions are left out, as in Word's "No Markup" view. With `comments` each page carries the
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
write::guard(|| {
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, numeral: opts.numeral, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout_inner(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> {
write::guard(|| export_layout_inner(doc, lay, opts))
}
fn export_layout_inner(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.saturating_add(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_attempt(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_attempt(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 language = doc.styles.default_chr.lang.clone().filter(|l| !l.is_empty() && l.is_ascii());
let mut writer = Writer::new_with_language(settings, metadata(doc, opts), language);
let pdf = writer.document_mut();
let mut ex = Exporter {
doc,
lay,
opts,
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
font_error: None,
outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
vectors: HashMap::new(),
tags: Vec::new(),
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
};
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
}
for &pi in pages {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi.saturating_add(1))) };
let fit = Fit::of(page, opts.paper);
let size = Size::from_wh(clamp_side(fit.paper_w), clamp_side(fit.paper_h)).ok_or(PdfError::NoPages)?;
let mut kp = pdf.start_page_with(PageSettings::new(size));
let mut s = kp.surface();
ex.links.clear();
// A page with a markup area is scaled down to fit its paper with the area beside it; a
// print on other paper is scaled to it and centred.
let scaled = fit.k != 1.0 || fit.dx != 0.0 || fit.dy != 0.0;
if scaled {
s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, fit.dx, fit.dy));
}
// `page_w` is the document page's width: the page colour and watermark stay off the markup area.
ex.page(&mut s, page, clamp_side(fit.page_w), clamp_side(page.h));
if scaled {
s.pop();
}
s.finish();
for a in ex.annotations(fit) {
kp.add_annotation(a);
}
kp.finish();
}
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
if let Some(error) = ex.font_error {
return Err(error);
}
match writer.finish() {
Ok(bytes) => Ok(Attempt::Done(bytes)),
Err(WriteError::Serialization(error)) => match *error {
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})"))),
}
}
error => Err(PdfError::Write(format!("{error:?}"))),
},
Err(error) => Err(error.into()),
}
}
/// How a layout page maps onto its PDF page: a page with a markup area (Print Markup) is scaled
/// by `k` to its paper width and centred vertically (`dy`). With [`PdfOptions::paper`] that
/// sheet is then scaled to the chosen paper and centred on it (`dx`, `dy`). Any other page is
/// drawn as it is.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fit {
/// The PDF page.
paper_w: f32,
paper_h: f32,
/// The document page's width in layout points (without the markup area).
page_w: f32,
k: f32,
dx: f32,
dy: f32,
}
impl Fit {
fn of(page: &Page, paper: Option<(f32, f32)>) -> Fit {
let paper_w = page.w - page.markup;
let own = if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 {
let k = paper_w / page.w;
Fit { paper_w, paper_h: page.h, page_w: paper_w, k, dx: 0.0, dy: page.h * (1.0 - k) / 2.0 }
} else {
Fit { paper_w: page.w, paper_h: page.h, page_w: page.w, k: 1.0, dx: 0.0, dy: 0.0 }
};
let Some((w, h)) = paper else { return own };
let f = wordcraft_geom::fit_to_paper(own.paper_w, own.paper_h, w, h);
Fit { paper_w: f.paper_w, paper_h: f.paper_h, page_w: own.page_w, k: own.k * f.k, dx: f.dx, dy: own.dy * f.k + f.dy }
}
/// A layout point on the PDF page.
fn point(self, x: f32, y: f32) -> (f32, f32) {
(x * self.k + self.dx, y * self.k + self.dy)
}
}
/// 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()
}
/// Whether a run's glyphs already run left to right, as PDF text extractors (PDFium in Chrome and
/// Edge, pdf.js, poppler) take them to: they read a run's glyphs as written left to right and turn
/// right-to-left words back into reading order themselves. Layout gives right-to-left text in
/// reading order.
fn left_to_right(glyphs: &[(u32, f32, f32)]) -> bool {
glyphs.windows(2).all(|w| matches!(w, [a, b] if a.1 <= b.1))
}
/// Per-glyph text ranges for clusters of several glyphs (a letter and its vowel marks) when each
/// glyph is one of the cluster's characters (through the font's character map, one glyph at most
/// left to take the character left over), so a viewer
/// reversing a right-to-left word puts every mark back after its letter. Other clusters, such
/// as a ligature drawn as two glyphs, keep their shared range.
fn split_clusters(
text: &str,
glyphs: &[(u32, f32, f32)],
ranges: &[std::ops::Range<usize>],
rev: &HashMap<u32, char>,
) -> Vec<std::ops::Range<usize>> {
let mut out = ranges.to_vec();
let mut i = 0;
while i < ranges.len() {
let Some(r) = ranges.get(i) else { break };
let n = ranges.get(i..).map_or(1, |rest| rest.iter().take_while(|x| *x == r).count()).max(1);
let chars: Vec<(usize, char)> = text.get(r.clone()).map(|t| t.char_indices().map(|(k, c)| (r.start + k, c)).collect()).unwrap_or_default();
if n > 1 && chars.len() == n {
// Glyphs the character map knows take their characters; one left over (a letter's
// contextual form, which the map doesn't list) takes the one character left.
let mut used = vec![false; n];
let mut of: Vec<Option<usize>> = (i..i + n)
.map(|g| {
let c = rev.get(&glyphs.get(g)?.0)?;
let k = chars.iter().enumerate().position(|(k, (_, ch))| ch == c && !used.get(k).copied().unwrap_or(true))?;
*used.get_mut(k)? = true;
Some(k)
})
.collect();
let free: Vec<usize> = (0..n).filter(|k| !used.get(*k).copied().unwrap_or(true)).collect();
if let ([k], [slot]) = (free.as_slice(), of.iter_mut().filter(|o| o.is_none()).collect::<Vec<_>>().as_mut_slice()) {
**slot = Some(*k);
}
let parts: Option<Vec<std::ops::Range<usize>>> = of
.iter()
.map(|k| {
let (a, ch) = chars.get((*k)?)?;
Some(*a..a + ch.len_utf8())
})
.collect();
if let (Some(parts), Some(dst)) = (parts, out.get_mut(i..i + n)) {
dst.clone_from_slice(&parts);
}
}
i += n;
}
out
}
/// Indices of a run's glyphs in visual order, left to right: its clusters (consecutive glyphs
/// sharing a range in `clusters`, such as a letter and its marks, or a lam-alef ligature) sorted
/// by their leftmost glyph and kept together. Inside a cluster, glyphs go in reverse order of
/// their own text (`parts`) when it is right to left, so a viewer reversing the word reads the
/// letter before its marks.
fn visual_order(text: &str, glyphs: &[(u32, f32, f32)], clusters: &[std::ops::Range<usize>], parts: &[std::ops::Range<usize>]) -> Vec<usize> {
let mut groups: Vec<(f32, Vec<usize>)> = Vec::new();
for (i, (_, x, _)) in glyphs.iter().enumerate() {
match groups.last_mut() {
Some((left, idx)) if i > 0 && clusters.get(i).is_some() && clusters.get(i) == clusters.get(i - 1) => {
*left = left.min(*x);
idx.push(i);
}
_ => groups.push((*x, vec![i])),
}
}
// Clusters at the same x go in text order, reversed when right to left: some fonts draw
// lam-alef as a lam and a zero-width alef at one x, and a right-to-left run whose ranges
// don't decrease loses its reading order (#2310). Stable for glyphs sharing their text.
let tie = |idx: &[usize]| -> i64 {
let Some(r) = idx.first().and_then(|&g| clusters.get(g)) else { return 0 };
let start = i64::try_from(r.start).unwrap_or(i64::MAX);
if text.get(r.clone()).is_some_and(|t| t.chars().any(wordcraft_fonts::is_rtl)) { -start } else { start }
};
groups.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| tie(&a.1).cmp(&tie(&b.1))));
groups
.into_iter()
.flat_map(|(_, mut idx)| {
let rtl = idx.first().and_then(|&g| text.get(clusters.get(g)?.clone())).is_some_and(|t| t.chars().any(wordcraft_fonts::is_rtl));
if rtl {
idx.sort_by_key(|&g| std::cmp::Reverse(parts.get(g).map_or(0, |r| r.start)));
}
idx
})
.collect()
}
/// The y most of a run's glyphs share: its baseline, which raised or lowered marks leave.
fn baseline(glyphs: &[(u32, f32, f32)]) -> Option<f32> {
let mut counts: HashMap<u32, usize> = HashMap::new();
for (_, _, y) in glyphs {
*counts.entry(y.to_bits()).or_insert(0) += 1;
}
// Ties go to the lower y bits, so the result doesn't depend on hash order.
counts.into_iter().max_by_key(|&(bits, n)| (n, std::cmp::Reverse(bits))).map(|(bits, _)| f32::from_bits(bits))
}
/// A chart's or diagram's alt text: its own, else what it is ("chart", "diagram").
fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
if alt.trim().is_empty() { kind.noun() } else { alt }
}
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
fn holds_picture(d: &Draw) -> bool {
match d {
Draw::Image { .. } => true,
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
_ => false,
}
}
/// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's.
fn figure_alt(alt: &str, kind: Option<GraphicKind>) -> &str {
match kind {
Some(k) => graphic_alt(alt, k),
None => alt,
}
}
/// 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 { creator: "WordCraft".into(), producer: "WordCraft".into(), ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| c.title.clone());
if !title.trim().is_empty() {
m.title = Some(title);
}
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
if !author.trim().is_empty() {
m.authors = vec![author];
}
if !c.subject.trim().is_empty() {
m.description = Some(c.subject.clone());
} else if !c.description.trim().is_empty() {
m.description = Some(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.keywords = kw;
}
let date = parse_iso(&c.created).or_else(|| now_unix().map(civil));
if let Some((y, mo, d, h, mi, s)) = date {
m.created =
Some(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 s = s.trim();
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))
}
/// The system clock, or the browser's on the web.
fn now_unix() -> Option<i64> {
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
{
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
i64::try_from(secs).ok()
}
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
{
let ms = js_sys::Date::now();
(ms.is_finite() && ms > 0.0).then(|| (ms / 1000.0) as i64)
}
}
/// Unix seconds (UTC) → civil time (proleptic Gregorian).
///
/// The year is returned unclamped (the caller clamps it to the PDF `DateTime` range 0..=9999);
/// month, day and time of day always come through correctly. Past chrono's ±262143-year range the
/// Gregorian 400-year cycle (146_097 days) keeps the result exact — chrono converts the day within
/// one cycle and whole cycles go back into the year (an `i128` so the extremes can't overflow) —
/// rather than falling back to the epoch, so the date never changes for a value the old
/// hand-rolled converter accepted. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil(t: i64) -> Civil {
use chrono::Datelike;
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
let (y, m, d) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(date) => {
let y = i128::from(date.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month() as u8, date.day() as u8)
}
None => (1970, 1, 1), // unreachable
};
(y, m, d, (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 }
}
/// A fill in paint `p`: a colour with its opacity, or a gradient whose stops carry theirs.
fn paint_fill(p: &Paint) -> Fill {
let stops = |stops: &[(f32, Rgb, f32)]| -> Vec<krilla::paint::Stop> {
stops
.iter()
.map(|(o, c, a)| krilla::paint::Stop { offset: norm(*o), color: rgb::Color::new(c.0, c.1, c.2).into(), opacity: norm(*a) })
.collect()
};
let paint: krilla::paint::Paint = match p {
Paint::Solid(c, a) => return fill(*c, *a),
Paint::Linear { p0, p1, stops: st } => krilla::paint::LinearGradient {
x1: p0.0,
y1: p0.1,
x2: p1.0,
y2: p1.1,
transform: Transform::default(),
spread_method: krilla::paint::SpreadMethod::Pad,
stops: stops(st),
anti_alias: true,
}
.into(),
Paint::Radial { c, r, stops: st } => krilla::paint::RadialGradient {
fx: c.0,
fy: c.1,
fr: 0.0,
cx: c.0,
cy: c.1,
cr: *r,
transform: Transform::default(),
spread_method: krilla::paint::SpreadMethod::Pad,
stops: stops(st),
anti_alias: true,
}
.into(),
};
Fill { paint, opacity: NormalizedF32::ONE, 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()
}
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
}
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
fn shape_effects(
s: &mut Surface,
kind: ShapeKind,
corner: Option<u32>,
rect: &Rect,
fx: &wordcraft_doc::effects::ShapeEffects,
filled: bool,
stroked: bool,
sw: f32,
) {
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
if filled {
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy), corner) {
s.set_stroke(None);
s.set_fill(Some(fill(c, a)));
s.draw_path(&p);
}
} else if sw + 2.0 * g > 0.05
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy), corner)
{
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
s.draw_path(&p);
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
silhouette(s, sh.color, a, g, dx, dy);
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
silhouette(s, gl.color, a, g, 0.0, 0.0);
}
}
s.set_fill(None);
s.set_stroke(None);
}
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer); `corner` is a
/// rounded rectangle's ([`wordcraft_doc::connector::ShapeExtra::corner`]).
fn shape_path(kind: ShapeKind, r: &Rect, corner: Option<u32>) -> Option<Path> {
let flat_ok = kind.is_open() && r.w.max(r.h) > 0.0 && r.w.min(r.h) >= 0.0;
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || !(flat_ok || 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 {
// A freeform is drawn from its own paths; without them, its frame.
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]),
ShapeKind::RoundedRectangle => {
let rad = wordcraft_doc::connector::corner_radius(corner, r.w, r.h);
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::FlowchartOr => {
ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0);
pb.move_to(x0, cy);
pb.line_to(x1, cy);
pb.move_to(cx, y0);
pb.line_to(cx, y1);
}
ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]),
ShapeKind::RightTriangle => poly(&mut pb, &[(x0, y0), (x1, y1), (x0, y1)]),
ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]),
// DrawingML's `line`: top-left to bottom-right (flips give the other directions).
ShapeKind::Line => {
pb.move_to(x0, y0);
pb.line_to(x1, y1);
}
// Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal.
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {
pb.move_to(x0, y0);
pb.line_to(x1, y1);
}
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>)>,
font_error: Option<PdfError>,
/// 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>>,
/// Parsed metafiles by media key (`None`: not readable).
vectors: HashMap<String, Option<Arc<Vector>>>,
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)>,
/// Bookmark positions by name (the first of a name wins), gathered once: looking each internal
/// link up by walking the whole document made a long document with a linked table of contents
/// take seconds to export.
bookmarks: std::cell::OnceCell<HashMap<String, wordcraft_doc::Pos>>,
}
/// 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;
}
let font_error = &mut self.font_error;
self.fonts
.entry(face.id())
.or_insert_with(|| {
let variations = face.is_variable().then_some(face.coords.as_slice());
let font = match write::font(face.data(), face.index(), variations) {
Ok(font) => Some(font),
// Keep WordCraft's existing policy: unreadable faces are skipped.
Err(WriteError::InvalidFont) => None,
Err(error) => {
// Allocation, validation or backend failures must fail the export,
// rather than return a PDF with missing text.
font_error.get_or_insert_with(|| error.into());
None
}
};
(*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()
}
/// The metafile picture for a media key, parsed once. None when the bytes are not a metafile
/// (the raster path takes them) or do not parse (the raster path then draws the grey box).
fn vector(&mut self, key: &str) -> Option<Arc<Vector>> {
let doc = self.doc;
let bytes = doc.media.get(key)?;
if !wordcraft_metafile::is_metafile(bytes) {
return None;
}
if let Some(v) = self.vectors.get(key) {
return v.clone();
}
let v = build_vector(bytes).map(Arc::new);
if v.is_none() {
log::warn!("PDF: metafile `{key}` could not be read");
}
self.vectors.insert(key.to_string(), v.clone());
v
}
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)] });
}
}
}
/// A rotated inline picture or group: turned as a whole, with each picture in it tagged on its
/// own, so a decorative picture is an artifact while the others stay figures with their alt
/// text. A described shape's figure keeps its description; anything else is an artifact.
fn rotated_pictures(&mut self, s: &mut Surface, d: &Draw) {
match d {
Draw::Rotated { cx, cy, spin, items } => {
if !ok(*cx) || !ok(*cy) {
return;
}
let [a, b, c, d, e, f] = spin.matrix(*cx, *cy);
s.push_transform(&Transform::from_row(a, b, c, d, e, f));
for it in items {
self.rotated_pictures(s, it);
}
s.pop();
}
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
Draw::Figure { alt, draws, .. } => 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| me.draw(s, d)),
}
}
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(pic) = self.doc.settings.page_picture.clone() {
self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.page_picture(s, &pic, w, h));
}
if let Some(wm) = self.doc.settings.watermark.clone()
&& !wm.text.trim().is_empty()
{
let area = page.body;
self.tagged(s, Role::Artifact(ArtifactType::Watermark), None, |me, s| me.watermark(s, &wm, w, h, area));
}
let dopts = DisplayOptions {
marks: false,
dim_header: false,
dim_body: false,
markup: self.opts.include_markup,
placeholders: false,
..Default::default()
};
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, para, .. } => {
let idx = self.entry(*story, path, para);
for d in &draws {
match d {
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// A picture marked decorative: an artifact screen readers skip.
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
// A rotated inline picture, or a rotated group holding pictures: turned as a
// whole, each picture tagged on its own (see `rotated_pictures`).
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
for d in inner {
me.draw(s, d);
}
}),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
}
}
Placed::Image { story, path, off, .. } => {
let obj = self.doc.para(*story, path).and_then(|p| p.object_at(*off));
let role = if obj.is_some_and(InlineObject::is_decorative) { Role::Artifact(ArtifactType::Other) } else { Role::Figure };
let alt = match obj {
Some(InlineObject::Image { alt, .. }) => alt.clone(),
_ => String::new(),
};
self.tagged(s, role, Some(&alt), |me, s| {
for d in &draws {
me.draw(s, d);
}
});
}
// A floating shape with a description: a figure (see `shape_figure_alt`).
Placed::Shape { alt, .. } if !alt.is_empty() => self.tagged(s, Role::Figure, Some(alt), |me, s| {
for d in &draws {
me.draw(s, d);
}
}),
Placed::Graphic { story, path, off, graphic, .. } => {
let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) {
Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(),
_ => graphic.kind.noun().to_string(),
};
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 mut items = vec![it.clone()];
// Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the
// cell among the items (cells themselves draw nothing).
if matches!(it, Placed::Lines { .. }) {
let clipped = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. }));
items.extend(clipped.cloned());
}
let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() };
page_display(self.doc, &one, dopts)
}
/// A heading's text for bookmarks and tags: what the pages show. That leaves out what its
/// layout `pl` left out (hidden text, resolved as laid out, so table formatting counts too) and,
/// without markup, tracked deletions.
fn title_text(&self, p: &wordcraft_doc::Paragraph, pl: &wordcraft_layout::para::ParaLayout) -> String {
let mut dropped = pl.left_out.clone();
if !self.opts.include_markup {
dropped.extend(p.deleted_ranges());
}
p.text_without(&dropped)
}
/// The structure entry for a paragraph (created on first sight, in reading order).
fn entry(&mut self, story: StoryRef, path: &DocPath, pl: &wordcraft_layout::para::ParaLayout) -> 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| self.title_text(p, pl).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::Figure { draws, .. } => {
for d in draws {
self.draw(s, d);
}
}
Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, x_scale, .. } => {
match glyphs.first().map(|g| g.1).filter(|_| x_scale.is_finite() && *x_scale > 0.0 && (x_scale - 1.0).abs() > 1e-4) {
// Scaled letters (`w:w`): drawn stretched across from the run's start, their
// positions brought back so the stretch puts each where the layout has it.
Some(x0) => {
let k = *x_scale;
let moved: Vec<(u32, f32, f32)> = glyphs.iter().map(|&(g, x, y)| (g, x0 + (x - x0) / k, y)).collect();
s.push_transform(&Transform::from_row(k, 0.0, 0.0, 1.0, x0 * (1.0 - k), 0.0));
self.glyphs(s, face, *size, &moved, *color, *alpha, *synth_bold, *synth_italic, text, ranges);
s.pop();
}
None => self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges),
}
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::Path { segs, fill: f, stroke, stroke_width, dash } => {
let mut pb = PathBuilder::new();
append_path(&mut pb, &wordcraft_layout::display::seg_path(segs));
let Some(p) = pb.finish() else { return };
s.set_fill(f.as_ref().map(paint_fill));
s.set_stroke(stroke.map(|c| Stroke {
paint: rgb::Color::new(c.0, c.1, c.2).into(),
// Width 0 (or none) is a hairline.
width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 },
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
..Default::default()
}));
if f.is_some() || stroke.is_some() {
s.draw_path(&p);
}
s.set_fill(None);
s.set_stroke(None);
}
Draw::Ink { pts, color, width, alpha } => {
let mut pb = PathBuilder::new();
let mut used = 0usize;
let mut last = (0.0, 0.0);
for &(x, y) in pts.iter().filter(|(x, y)| ok(*x) && ok(*y)) {
if used > 0 {
pb.line_to(x, y);
} else {
pb.move_to(x, y);
}
used += 1;
last = (x, y);
}
// A tap (one usable point): a zero-length line, which round caps draw as a dot.
if used == 1 {
pb.line_to(last.0, last.1);
}
let Some(p) = pb.finish() else { return };
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: rgb::Color::new(color.0, color.1, color.2).into(),
width: if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 },
opacity: norm(*alpha),
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Default::default()
}));
s.draw_path(&p);
s.set_stroke(None);
}
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects, corner, dash } => {
let Some(p) = shape_path(*kind, rect, *corner) else { return };
let can_fill = *kind != ShapeKind::Line;
let fx = effects.sanitized();
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
shape_effects(s, *kind, *corner, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(c), true) = (fx.soft_edge, f.as_ref(), can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
s.set_stroke(None);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) {
s.set_fill(Some(paint_fill(&c.faded(a))));
s.draw_path(&p);
}
}
s.set_fill(None);
return;
}
s.set_fill(f.as_ref().filter(|_| can_fill).map(paint_fill));
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 },
dash: (!dash.is_empty()).then(|| StrokeDash { array: dash.clone(), offset: 0.0 }),
..Default::default()
}));
if f.is_some() && can_fill || stroke.is_some() {
s.draw_path(&p);
}
s.set_fill(None);
s.set_stroke(None);
}
Draw::Art { path, fx, color, alpha, .. } => {
for l in wordcraft_layout::wordart::art_layers(path, fx, *color, *alpha) {
let mut pb = PathBuilder::new();
append_path(&mut pb, &l.path);
let Some(p) = pb.finish() else { continue };
let Fill { paint, opacity, .. } = paint_fill(&l.paint);
match l.stroke {
Some(w) => {
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint,
width: w.clamp(0.1, 400.0),
opacity,
line_join: LineJoin::Round,
..Default::default()
}));
}
None => {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: FillRule::NonZero }));
}
}
s.draw_path(&p);
}
s.set_fill(None);
s.set_stroke(None);
}
Draw::Mark { .. } | Draw::MarkText { .. } => {}
Draw::Clip { rect, items } => {
let Some(clip) = rect_path(rect) else { return };
s.push_clip_path(&clip, &FillRule::NonZero);
for it in items {
self.draw(s, it);
}
s.pop();
}
Draw::Turned { x, y, turn, items } => {
if !ok(*x) || !ok(*y) {
return;
}
let [a, b, c, d, e, f] = Draw::turn_matrix(*turn, *x, *y);
s.push_transform(&Transform::from_row(a, b, c, d, e, f));
for it in items {
self.draw(s, it);
}
s.pop();
}
Draw::Rotated { cx, cy, spin, items } => {
if !ok(*cx) || !ok(*cy) {
return;
}
let [a, b, c, d, e, f] = spin.matrix(*cx, *cy);
s.push_transform(&Transform::from_row(a, b, c, d, e, f));
for it in items {
self.draw(s, it);
}
s.pop();
}
}
}
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 | LineStyle::Dashed | LineStyle::BorderDotted | LineStyle::BorderDashed | LineStyle::BorderDotDash => {
let array = style.dashes().unwrap_or(&[]).iter().map(|k| k * w).collect();
s.set_stroke(Some(stroke(w, Some(StrokeDash { array, 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::Triple => {
// Three thin lines, spaced as a double's, across the line's direction.
let (dx, dy) = (x1 - x0, y1 - y0);
let len = (dx * dx + dy * dy).sqrt().max(1e-3);
let (nx, ny) = (-dy / len * w * 1.8, dx / len * w * 1.8);
s.set_stroke(Some(stroke(w * 0.6, None, LineCap::Butt)));
seg(s, nx, ny);
seg(s, 0.0, 0.0);
seg(s, -nx, -ny);
}
LineStyle::Wave => {
let amp = (w * 1.2).max(0.8);
let step = amp * 2.0;
let (dx, dy) = (x1 - x0, y1 - y0);
let len = (dx * dx + dy * dy).sqrt();
if len >= 1e-3 {
let (ux, uy) = (dx / len, dy / len);
let (nx, ny) = (-uy, ux);
let mut pb = PathBuilder::new();
pb.move_to(x0, y0);
let mut t = 0.0;
let mut up = true;
while t < len && t < 20_000.0 {
let nt = (t + step).min(len);
let mid_t = (t + nt) / 2.0;
let sign = if up { -1.0 } else { 1.0 };
let cx = x0 + ux * mid_t + nx * amp * sign;
let cy = y0 + uy * mid_t + ny * amp * sign;
let ex = x0 + ux * nt;
let ey = y0 + uy * nt;
pb.quad_to(cx, cy, ex, ey);
t = nt;
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);
}
/// The page's picture or texture fill: tiled at 96 dpi for a `tile` fill (when that is a sane
/// number of tiles), else stretched over the page.
fn page_picture(&mut self, s: &mut Surface, pic: &wordcraft_doc::PagePicture, w: f32, h: f32) {
const MAX_TILES: f32 = 4096.0;
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return;
}
let dims = self
.doc
.media
.get(&pic.media)
.and_then(|b| image::ImageReader::new(std::io::Cursor::new(b.as_slice())).with_guessed_format().ok()?.into_dimensions().ok());
if let Some((pw, ph)) = dims.filter(|_| pic.fill_type.eq_ignore_ascii_case("tile")) {
let (tw, th) = (pw as f32 * 0.75, ph as f32 * 0.75);
if tw >= 1.0 && th >= 1.0 && (w / tw).ceil() * (h / th).ceil() <= MAX_TILES {
let mut y = 0.0;
while y < h {
let mut x = 0.0;
while x < w {
self.picture(s, &Rect::new(x, y, tw, th), &pic.media, &[0.0; 4], 1.0);
x += tw;
}
y += th;
}
return;
}
}
self.picture(s, &Rect::new(0.0, 0.0, w, h), &pic.media, &[0.0; 4], 1.0);
}
fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) {
let Some(clip) = rect_path(rect) else { return };
if let Some(v) = self.vector(media) {
vector_picture(self, s, &v, rect, crop, &clip, alpha);
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, vis_w, vis_h] = wordcraft_metafile::crop_window(*crop).map(|v| v as f32);
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,
known: &[std::ops::Range<usize>],
) {
if !size.is_finite() || size <= 0.0 || size > 5000.0 {
return;
}
if glyphs.is_empty() {
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;
}
// Layout's glyph → text mapping when it has one (right-to-left text, contextual forms and
// ligatures can't be guessed back from the font's cmap), else a guess.
let valid = known.len() == glyphs.len() && known.iter().all(|r| r.start < r.end && text.get(r.clone()).is_some());
let (txt, ranges) = if valid { (text.to_string(), known.to_vec()) } else { self.map_text(face, glyphs, text) };
let upem = face.upem.max(1.0) as f32;
// Right-to-left text is written left to right as it appears (see `left_to_right`); glyphs
// already in that order are written as they are.
let (order, ranges) = if left_to_right(glyphs) {
((0..glyphs.len()).collect(), ranges)
} else {
let parts = split_clusters(&txt, glyphs, &ranges, &self.cmap(face));
(visual_order(&txt, glyphs, &ranges, &parts), parts)
};
let placed: Vec<(u32, f32, f32, std::ops::Range<usize>)> =
order.iter().filter_map(|&i| Some((glyphs.get(i).copied()?, ranges.get(i).cloned()?))).map(|((g, x, y), r)| (g, x, y, r)).collect();
let Some(&(_, x0, y0, _)) = placed.first() else { return };
// The run's baseline: the y most of its glyphs share (marks sit higher or lower).
let base = baseline(glyphs).unwrap_or(y0);
let kg: Vec<KrillaGlyph> = placed
.iter()
.enumerate()
.map(|(k, (g, x, y, r))| {
let adv = match placed.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.clone(), None)
})
.collect();
let pushed = italic;
if italic {
s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * base, 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) — the page and line its start lands on, which
/// in a paragraph split across pages need not be the paragraph's first page.
fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> {
let marks = self.bookmarks.get_or_init(|| {
let mut m = HashMap::new();
for (n, pos) in self.doc.bookmarks() {
m.entry(n).or_insert(pos);
}
m
});
let pos = marks.get(name).or_else(|| marks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?.clone();
if let Some(c) = self.lay.caret(&pos)
&& let Some(out) = self.out_index.get(&c.page)
{
// The left edge of the paragraph's piece on that page, at the top of the line.
let x = self
.lay
.index
.get(&(pos.story, pos.path.clone()))
.into_iter()
.flatten()
.filter(|(pi, _)| *pi == c.page)
.find_map(|(pi, ii)| match self.lay.pages.get(*pi).and_then(|p| p.items.get(*ii)) {
Some(Placed::Lines { x, .. }) => Some(*x),
_ => None,
})
.unwrap_or(c.x);
let (x, y) = self.lay.pages.get(c.page).map(|p| Fit::of(p, self.opts.paper).point(x, c.top)).unwrap_or((x, c.top));
return Some((*out, x, y));
}
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 Some(page) = self.lay.pages.get(*pi) else { continue };
let (x, y) = match page.items.get(*ii) {
Some(Placed::Lines { x, y, .. }) => Fit::of(page, self.opts.paper).point(*x, *y),
_ => (0.0, 0.0),
};
return Some((*out, x, y));
}
None
}
fn annotations(&self, fit: Fit) -> Vec<Annotation> {
let mut v = Vec::new();
for (x0, y0, x1, y1, target) in &self.links {
let ((x0, y0), (x1, y1)) = (fit.point(*x0, *y0), fit.point(*x1, *y1));
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, para, l0, l1, 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 };
// A leading page break can be placed on the previous page without the heading.
let first = para
.lines
.iter()
.take_while(|line| {
line.end == LineEnd::PageBreak
&& para
.clusters
.get(line.c0..line.c1)
.is_some_and(|cs| cs.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker)))
})
.count();
if !(*l0..*l1).contains(&first) {
continue;
}
let Some(line) = para.lines.get(first) else { continue };
let Some(piece) = para.lines.get(*l0) else { continue };
let title: String = self.title_text(p, para).split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect();
if title.is_empty() {
continue;
}
let (x, y) = Fit::of(page, self.opts.paper).point(*x, *y + line.top - piece.top);
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)
}
/// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps
/// the run's own space (see [`PlacedItem::Text`]) to the page.
fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return };
let font = self.font(&face);
if font.is_none() && !self.outlined.contains(&face.id()) {
return;
}
let em = lay.em as f32;
let c = tf.as_coeffs().map(|v| v as f32);
if !c.iter().all(|v| v.is_finite()) || !em.is_finite() {
return;
}
let (paint, opacity) = paint_of(t.color);
s.push_transform(&Transform::from_row(c[0], c[1], c[2], c[3], c[4], c[5]));
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint.into(), opacity, rule: FillRule::NonZero }));
match font {
Some(font) => {
let upem = face.upem.max(1.0) as f32;
let kg: 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(t.text.len());
let start = g.cluster.min(end);
let x = lay.glyphs.get(i).map_or(0.0, |p| p.0);
let adv = match lay.glyphs.get(i + 1) {
Some(n) => ((n.0 - x) / lay.em) as f32,
None => g.x_advance as f32 / upem,
};
let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32);
KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None)
})
.collect();
let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32;
s.draw_glyphs(Point::from_xy(x0, lay.baseline as f32), &kg, font, &t.text, em, false);
}
None => {
let pen: Vec<(u32, f32, f32)> = shaped.iter().zip(&lay.glyphs).map(|(g, (x, y))| (g.gid, *x as f32, *y as f32)).collect();
if let Some(path) = glyph_outlines(&face, em, &pen) {
s.draw_path(&path);
}
}
}
s.set_fill(None);
s.pop();
}
/// The watermark text stretched over its box (Word's `v:textpath fitshape`): its advance across
/// the box's width, its em down the box's height with the em's foot (the font's descent below
/// the baseline) on the box's bottom; the box centred on the margins (`area`, else the page)
/// and, when diagonal, turned 45° counterclockwise.
fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32, area: Rect) {
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 [bw, bh] = wm.frame(w, h);
let size = bh.min(2000.0);
let stretch = bw / (raw_w * size);
let descent = (wordcraft_fonts::word::line_metrics(&face).1 / face.upem.max(1.0)) as f32;
let angle = if wm.diagonal { -std::f32::consts::FRAC_PI_4 } else { 0.0 };
let (c, sn) = (angle.cos(), angle.sin());
let (dx, dy) = (-bw / 2.0, bh / 2.0 - descent.clamp(0.0, 1.0) * bh);
let (cx, cy) = if area.w > 0.0 && area.h > 0.0 { (area.x + area.w / 2.0, area.y + area.h / 2.0) } else { (w / 2.0, h.min(2000.0) / 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 * stretch, sn * stretch, -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();
}
}
/// A metafile picture, parsed once. The geometry comes placed by the metafile crate; the bitmaps are
/// embedded images, one per picture item (None for paths and undecodable bitmaps).
struct Vector {
pic: Picture,
images: Vec<Option<Image>>,
}
/// Parses a metafile and embeds its bitmaps. None when the picture has no usable size.
fn build_vector(bytes: &[u8]) -> Option<Vector> {
let mut pic = wordcraft_metafile::parse(bytes).ok()?;
if !pic.has_size() {
return None;
}
let images = pic.items.iter_mut().map(|it| it.take_pixels().map(|(w, h, px)| Image::from_rgba8(px, w, h))).collect();
Some(Vector { pic, images })
}
/// A straight RGBA colour as a krilla colour and opacity.
fn paint_of(c: [u8; 4]) -> (rgb::Color, NormalizedF32) {
(rgb::Color::new(c[0], c[1], c[2]), norm(f32::from(c[3]) / 255.0))
}
/// Narrowest metafile stroke in points. Cosmetic (width 0) pens get it rather than PDF's 0-width
/// "thinnest line": krilla bounds a 0-width stroke by the bare path, which is empty for a horizontal
/// line, so a faded (grouped) picture would clip it away.
const HAIRLINE: f64 = 0.25;
/// Draws a metafile picture into the clip (the picture frame): paths are filled and stroked in page
/// space (strokes keep their width under non-uniform scaling), bitmaps stretched, all faded by `alpha`.
/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. Text
/// runs are drawn as text.
fn vector_picture(ex: &mut Exporter, s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) {
let target = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x + r.w), f64::from(r.y + r.h));
let fade = alpha < 0.999;
if fade {
s.push_opacity(norm(alpha));
}
s.push_clip_path(clip, &FillRule::NonZero);
for it in v.pic.place(target, *crop) {
match it {
PlacedItem::Path { path, fill, stroke, even_odd } => {
let Some(p) = path_from(&path) else { continue };
let fill = fill.map(|c| {
let (paint, opacity) = paint_of(c);
let rule = if even_odd { FillRule::EvenOdd } else { FillRule::NonZero };
Fill { paint: paint.into(), opacity, rule }
});
let stroke = stroke.map(|(c, w)| {
let (paint, opacity) = paint_of(c);
Stroke {
paint: paint.into(),
width: w.max(HAIRLINE) as f32,
opacity,
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Default::default()
}
});
s.set_fill(fill);
s.set_stroke(stroke);
s.draw_path(&p);
}
PlacedItem::Bitmap { index, rect } => {
let Some(Some(img)) = v.images.get(index) else { continue };
let Some(size) = Size::from_wh(rect.width() as f32, rect.height() as f32) else { continue };
s.push_transform(&Transform::from_translate(rect.x0 as f32, rect.y0 as f32));
s.draw_image(img.clone(), size);
s.pop();
}
PlacedItem::Text { index, tf } => {
if let Some(wordcraft_metafile::Item::Text(t)) = v.pic.items.get(index) {
ex.vector_text(s, t, tf);
}
}
}
}
s.set_fill(None);
s.set_stroke(None);
s.pop();
if fade {
s.pop();
}
}
/// A kurbo path as a krilla path (None when empty).
fn path_from(b: &kurbo::BezPath) -> Option<Path> {
let mut pb = PathBuilder::new();
append_path(&mut pb, b);
pb.finish()
}
/// 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> {
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
}
/// [`load_image`] with an explicit RGBA byte budget (a small one in tests).
fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
let fmt = image::guess_format(data).ok()?;
// Header-only size check before anything is allocated (decode buffer + RGBA conversion are
// charged); the side limits and the default 512 MB allocation cap stay as a second guard.
let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?;
if !wordcraft_geom::decode_fits(hw, hh, budget) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_SIDE);
limits.max_image_height = Some(MAX_IMAGE_SIDE);
reader.limits(limits);
let dec = reader.decode().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.into_rgba8().into_raw(), w, h))
}
#[cfg(test)]
mod tests;