Files
designcraft/crates/pdf/src/export.rs
T
2026-10-14 22:05:11 -04:00

1819 lines
78 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.
//! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`.
use std::collections::HashMap;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use craft_pdf::write::{self, Metadata, Writer};
use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{BlendMode as DcBlend, Color, GradientKind};
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, StrokeAlign, StrokeType};
use designcraft_geom::kurbo::{PathEl, Shape as _};
use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::image::Image;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Tag, TagGroup, TagTree};
use crate::{ExportReport, PdfError, PdfOptions, Result, Standard};
/// Export `doc` as PDF bytes.
pub fn export_pdf(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<Vec<u8>> {
export_pdf_with_report(doc, cache, opts).map(|r| r.bytes)
}
/// One output page: a document page or a spread, in spread coordinates.
pub(crate) struct Sheet {
pub spread: usize,
pub trim: Rect,
pub bleed: Rect,
pub media: Rect,
/// Page name(s) for the page-information mark.
pub label: String,
}
pub(crate) const MARK_LEN: f64 = 18.0;
fn bleed_lr(side: PageSide, b: [f64; 4]) -> (f64, f64) {
// Bleed is top, bottom, inside, outside; single-sided pages read inside as left.
if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }
}
/// The spread each exported PDF page shows (in page order).
pub fn sheet_spreads(doc: &Document, opts: &PdfOptions) -> Vec<usize> {
sheets(doc, opts).map(|v| v.iter().map(|s| s.spread).collect()).unwrap_or_default()
}
fn sheets(doc: &Document, opts: &PdfOptions) -> Result<Vec<Sheet>> {
let count = doc.page_count();
let pages: Vec<usize> = match &opts.pages {
Some(v) => v.clone(),
None => (0..count).collect(),
};
if pages.is_empty() {
return Err(PdfError::NoPages);
}
if let Some(bad) = pages.iter().find(|p| **p >= count) {
return Err(PdfError::BadPage(bad.saturating_add(1)));
}
let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 });
let mut out: Vec<Sheet> = Vec::new();
let mut seen_spreads: Vec<usize> = Vec::new();
for abs in pages {
let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs.saturating_add(1)))?;
let sp: &Spread = &doc.spreads[si];
let (trim, l, r, label) = if opts.spreads {
if seen_spreads.contains(&si) {
continue;
}
seen_spreads.push(si);
let first = doc.first_page_of_spread(si);
let (l, _) = sp.pages.first().map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
let (_, r) = sp.pages.last().map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
let names: Vec<String> = (0..sp.pages.len()).map(|i| doc.page_name(first + i)).collect();
(sp.bounds(), l, r, names.join("–"))
} else {
let p = &sp.pages[pi];
let (l, r) = bleed_lr(p.side, b);
(p.bounds(), l, r, doc.page_name(abs))
};
let bleed = Rect::new(trim.x0 - l, trim.y0 - b[0], trim.x1 + r, trim.y1 + b[1]);
let media = if opts.marks.any() {
let m = mark_start(opts, b) + MARK_LEN + 6.0;
trim.inflate(m, m)
} else {
bleed
};
out.push(Sheet { spread: si, trim, bleed, media, label });
}
Ok(out)
}
/// Distance from the trim edge where marks start: the offset, but never inside the bleed.
pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
bleed.iter().copied().fold(opts.marks.offset.max(0.0), f64::max)
}
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
}
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
let sheets = sheets(doc, opts)?;
let warnings = Vec::new();
let (version, archival) = match opts.standard {
Standard::None => (PdfVersion::Pdf17, None),
Standard::PdfX4 => (PdfVersion::Pdf16, None),
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
};
let configuration = write::configuration(version, archival)?;
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let created = opts.created.or_else(now_unix);
let metadata = Metadata {
creator: "DesignCraft".into(),
producer: "DesignCraft".into(),
title: Some(title.clone()),
authors: opts.author.iter().cloned().collect(),
created: created.map(date_time),
..Default::default()
};
let mut writer = Writer::new(settings, metadata);
let pdf = writer.document_mut();
let mut ex = Exporter {
doc,
cache,
opts,
clip: Rect::ZERO,
warnings,
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
version,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: archival.is_some(),
interpolate: archival.is_none(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
};
if ex.rgb_only {
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
}
if let Some(o) = crate::links::outline(doc, &sheets) {
pdf.set_outline(o);
}
for (sheet_idx, sh) in sheets.iter().enumerate() {
let size = Size::from_wh(sh.media.width().max(1.0) as f32, sh.media.height().max(1.0) as f32).ok_or(PdfError::NoPages)?;
let local = |r: Rect| {
krilla::geom::Rect::from_ltrb(
(r.x0 - sh.media.x0) as f32,
(r.y0 - sh.media.y0) as f32,
(r.x1 - sh.media.x0) as f32,
(r.y1 - sh.media.y0) as f32,
)
};
let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed));
let mut page = pdf.start_page_with(settings);
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2())));
ex.clip = sh.bleed;
if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) {
s.push_clip_path(&clip, &FillRule::NonZero);
ex.spread(&mut s, sh.spread);
s.pop();
}
if opts.tagged {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Page, None)));
}
ex.marks(&mut s, sh, &title, created);
if opts.tagged {
s.end_tagged();
}
s.pop();
s.finish();
for a in crate::links::annotations(doc, cache, &sheets, sheet_idx) {
page.add_annotation(a);
}
page.finish();
}
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
let mut warnings = ex.warnings;
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) {
// The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any
// group that got past it, with its page.
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X.
let fields = crate::forms::collect(doc, &sheets);
let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() };
if !fields.is_empty() || !media.is_empty() {
if opts.standard == Standard::PdfX4 {
warnings.push("PDF/X-4: form fields and media were left out".into());
} else {
match crate::forms::add_fields(&bytes, &fields, &media) {
Some(b) => bytes = b,
None => warnings.push("form fields and media couldn't be added to this PDF".into()),
}
}
}
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
}
for issue in crate::pdfx::check_pdfx4(&bytes) {
warnings.push(format!("PDF/X-4: {issue}"));
}
}
warnings.dedup();
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
}
/// One PDF from several (a book's documents), every page carried over as vectors; each part
/// comes with its pages' sizes (points).
pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
write::guard(|| merge_pdfs_inner(parts, title))
}
fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: title.map(str::to_owned), ..Default::default() };
let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata);
let pdf = writer.document_mut();
let mut pages = 0;
for (bytes, sizes) in parts {
let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?;
let doc = krilla::pdf::PdfDocument::new(Arc::new(src));
for (i, &(w, h)) in sizes.iter().enumerate() {
let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?;
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
s.draw_pdf_page(&doc, size, i);
s.finish();
page.finish();
pages += 1;
}
}
if pages == 0 {
return Err(PdfError::NoPages);
}
writer.finish().map_err(PdfError::from)
}
/// File › Print Booklet: how pages pair up on printer spreads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BookletKind {
/// Folded and stapled: pages padded to a multiple of 4, outer pages together.
#[default]
SaddleStitch,
/// Two pages side by side in reading order.
TwoUpConsecutive,
}
#[derive(Clone, Debug, Default)]
pub struct BookletOptions {
pub kind: BookletKind,
/// Gap between the two pages of a printer spread (points).
pub space_between: f64,
pub title: Option<String>,
}
/// The printer spreads for `n` pages: (left, right) absolute page indices, `None` = blank.
pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option<usize>, Option<usize>)> {
let page = |i: usize| (i < n).then_some(i);
match kind {
BookletKind::TwoUpConsecutive => (0..n.div_ceil(2)).map(|k| (page(2 * k), page(2 * k + 1))).collect(),
BookletKind::SaddleStitch => {
let m = n.div_ceil(4).max(1) * 4;
(0..m / 2).map(|i| if i % 2 == 0 { (page(m - 1 - i), page(i)) } else { (page(i), page(m - 1 - i)) }).collect()
}
}
}
/// An imposed PDF: two document pages per sheet, in booklet order.
pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
write::guard(|| export_booklet_inner(doc, cache, opts))
}
fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
let n = doc.page_count();
if n == 0 {
return Err(PdfError::NoPages);
}
let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() };
let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() };
let mut writer = Writer::new(settings, metadata);
let pdf = writer.document_mut();
let mut ex = Exporter {
doc,
cache,
opts: &pdf_opts,
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
// krilla's default.
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
interpolate: true,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
};
let pairs = booklet_pairs(n, opts.kind);
// Sheet size from the largest page.
let (mut pw, mut ph) = (0.0f64, 0.0f64);
for i in 0..n {
if let Some((si, pi)) = doc.page_loc(i) {
let p = &doc.spreads[si].pages[pi];
pw = pw.max(p.width);
ph = ph.max(p.height);
}
}
let gap = opts.space_between.max(0.0);
let size = Size::from_wh((2.0 * pw + gap) as f32, ph as f32).ok_or(PdfError::NoPages)?;
for (left, right) in &pairs {
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
for (slot, abs) in [(0.0, left), (pw + gap, right)] {
let Some((si, pi)) = abs.and_then(|a| doc.page_loc(a)) else { continue };
let p = &doc.spreads[si].pages[pi];
let r = Rect::new(p.x, 0.0, p.x + p.width, p.height);
// Pages narrower than the slot sit against the fold.
let dx = if slot == 0.0 { pw - p.width } else { 0.0 };
s.push_transform(&tf(Affine::translate((slot + dx - p.x, (ph - p.height) / 2.0))));
ex.clip = r;
if let Some(clip) = to_path(&r.to_path(0.1)) {
s.push_clip_path(&clip, &FillRule::NonZero);
ex.spread(&mut s, si);
s.pop();
}
s.pop();
}
s.finish();
page.finish();
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk {
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, pages: pairs.len(), warnings })
}
#[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
}
fn date_time(t: i64) -> krilla::metadata::DateTime {
let c = civil(t);
krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16)
.month(c.1)
.day(c.2)
.hour(c.3)
.minute(c.4)
.second(c.5)
.utc_offset_hour(0)
.utc_offset_minute(0)
}
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
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)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
#[derive(Clone)]
struct ConvertedImage {
image: Image,
warning: Option<&'static str>,
}
const CMYK_FALLBACK_WARNING: &str = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)";
#[cfg(not(test))]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage>;
// Unit tests force collisions through the real cache path; production keeps RandomState.
#[cfg(test)]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage, std::hash::BuildHasherDefault<ConstantImageHasher>>;
#[cfg(test)]
#[derive(Default)]
struct ConstantImageHasher;
#[cfg(test)]
impl std::hash::Hasher for ConstantImageHasher {
fn finish(&self) -> u64 {
0
}
fn write(&mut self, _: &[u8]) {}
}
pub(crate) struct Exporter<'a> {
pub doc: &'a Document,
pub cache: &'a Cache,
pub opts: &'a PdfOptions,
/// Current sheet's visible area (spread space) for culling.
pub clip: Rect,
pub warnings: Vec<String>,
images: HashMap<AssetId, Option<Image>>,
// Immutable export options (including rgb_only) make bytes the complete conversion key.
// PDF-page/SVG/media routing precedes this path. Failures remain per AssetId.
images_by_content: ImageContentCache,
#[cfg(test)]
image_conversions: usize,
/// Placed PDFs (embedded as vector pages).
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
/// The PDF version written; placed PDFs must not be newer.
version: PdfVersion,
/// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails.
too_new: Vec<String>,
/// Parsed placed SVGs.
svgs: HashMap<AssetId, Option<Arc<designcraft_images::usvg::Tree>>>,
pub fonts: HashMap<u32, Option<krilla::text::Font>>,
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
pub rgb_only: bool,
/// Ask viewers to smooth upscaled images (`/Interpolate`). Off for PDF/A, which forbids it.
interpolate: bool,
/// Tagged PDF: the structure in reading order (stories gather their frames' content).
tags: Vec<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
/// While drawing a story's frame for the structure tree: the story (text runs are tagged
/// per paragraph, everything else in the frame is an artifact).
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
/// Marked content of each (story, paragraph index).
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
}
enum TagEntry {
Story(designcraft_doc::StoryId),
Figure(TagGroup),
}
pub(crate) fn tf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
pub(crate) fn to_path(bp: &BezPath) -> Option<Path> {
let mut pb = PathBuilder::new();
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => pb.close(),
}
}
pb.finish()
}
pub(crate) 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 q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
use krilla::color::RegularColor as R;
if rgb_only && matches!(c, Color::Cmyk { .. }) {
let [r, g, b] = c.to_rgb();
return R::Rgb(rgb::Color::new(q(r), q(g), q(b)));
}
match *c {
Color::Rgb { r, g, b } => R::Rgb(rgb::Color::new(q(r), q(g), q(b))),
Color::Cmyk { c, m, y, k } => R::Cmyk(cmyk::Color::new(q(c), q(m), q(y), q(k))),
// DesignCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness.
Color::Gray { k } => R::Luma(luma::Color::new(q(1.0 - k))),
}
}
/// A process colour in its own device space (CMYK → DeviceCMYK, RGB → DeviceRGB, Gray → DeviceGray).
/// `rgb_only` converts CMYK to RGB (PDF/A without a CMYK output profile).
pub(crate) fn device(c: &Color, rgb_only: bool) -> krilla::color::Color {
match regular(c, rgb_only) {
krilla::color::RegularColor::Rgb(v) => v.into(),
krilla::color::RegularColor::Cmyk(v) => v.into(),
krilla::color::RegularColor::Luma(v) => v.into(),
}
}
/// `[Registration]` prints on every plate: `/Separation /All`.
pub(crate) fn registration(tint: f32, rgb_only: bool) -> krilla::color::Color {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let alt = regular(&Color::cmyk(1.0, 1.0, 1.0, 1.0), rgb_only);
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into()
}
fn blend(b: DcBlend) -> krilla::blend::BlendMode {
use krilla::blend::BlendMode as K;
match b {
DcBlend::Normal => K::Normal,
DcBlend::Darken => K::Darken,
DcBlend::Multiply => K::Multiply,
DcBlend::ColorBurn => K::ColorBurn,
DcBlend::Lighten => K::Lighten,
DcBlend::Screen => K::Screen,
DcBlend::ColorDodge => K::ColorDodge,
DcBlend::Overlay => K::Overlay,
DcBlend::SoftLight => K::SoftLight,
DcBlend::HardLight => K::HardLight,
DcBlend::Difference => K::Difference,
DcBlend::Exclusion => K::Exclusion,
DcBlend::Hue => K::Hue,
DcBlend::Saturation => K::Saturation,
DcBlend::Color => K::Color,
DcBlend::Luminosity => K::Luminosity,
}
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
pub(crate) fn solid_fill(c: krilla::color::Color, opacity: f32) -> Fill {
Fill { paint: c.into(), opacity: norm(opacity), rule: FillRule::NonZero }
}
impl Exporter<'_> {
/// A print document, or a PDF/X file (whose output intent is CMYK).
pub(crate) fn for_print(&self) -> bool {
self.doc.settings.intent == designcraft_doc::Intent::Print || self.opts.standard == Standard::PdfX4
}
pub(crate) fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
/// A swatch + tint as a PDF colour: spot swatches (and tints of them) become `/Separation`
/// with the swatch's values as the alternate space; process colours stay in their device space.
pub(crate) fn swatch_color(&self, name: &str, tint: f32) -> Option<krilla::color::Color> {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let mut n = name;
let mut t = tint;
for _ in 0..8 {
let sw = self.doc.swatch(n)?;
match &sw.value {
SwatchValue::None => return None,
// [Paper] prints no ink (it knocks out what lies below); its colour is only what
// the screen shows. Written as RGB white (`1 1 1 rg`) it would put DeviceRGB in a
// CMYK print file.
SwatchValue::Paper { .. } if self.for_print() => return Some(device(&Color::cmyk(0.0, 0.0, 0.0, 0.0), self.rgb_only)),
SwatchValue::Tint { base, tint: bt } => {
t *= bt;
n = base;
}
SwatchValue::Registration => return Some(registration(t, self.rgb_only)),
SwatchValue::Color { color: _, color_type: ColorType::Spot } => {
// Ink Manager: an alias prints on another ink; converted inks go to process.
let (ink, process) = self.doc.inks.resolve(&sw.name);
let target =
self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw);
let SwatchValue::Color { color, .. } = &target.value else { break };
if process {
return Some(device(&designcraft_color::swatch::apply_tint(*color, t), self.rgb_only));
}
let space = SeparationSpace::new(SeparationColorant::Custom(target.name.clone()), regular(color, self.rgb_only));
return Some(SepColor::new(q(t), space).into());
}
_ => break,
}
}
self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only))
}
fn spread(&mut self, s: &mut Surface, si: usize) {
let doc = self.doc;
let Some(sp) = doc.spreads.get(si) else { return };
let first = doc.first_page_of_spread(si);
for layer in doc.layers.iter().rev() {
if !layer.visible || !layer.printable {
continue;
}
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppage].x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
}
for it in &sp.items {
if it.layer == layer.id {
self.top_item(s, it, Affine::IDENTITY, None, false);
}
}
}
}
/// Push blend/opacity for an item drawn as a group. Returns the number of pushes.
fn push_group(s: &mut Surface, opacity: f32, mode: DcBlend) -> usize {
let mut n = 0;
if mode != DcBlend::Normal {
s.push_blend_mode(blend(mode));
n += 1;
}
if opacity < 0.999 {
s.push_opacity(norm(opacity));
n += 1;
} else if mode != DcBlend::Normal {
s.push_isolated();
n += 1;
}
n
}
/// Isolate Blending / Knockout groups: an isolated transparency group (knockout is drawn
/// isolated too).
fn push_isolation(s: &mut Surface, it: &Item) -> usize {
if it.isolate || it.knockout {
s.push_isolated();
1
} else {
0
}
}
/// A spread or parent item, inside its structure tag when tagging.
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>, on_parent: bool) {
if !self.opts.tagged || it.hidden || it.nonprinting {
return self.item(s, it, parent, page);
}
let story = it.text_frame().map(|t| t.story);
let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items();
if on_parent || it.export_options.artifact || (story.is_none() && !figure) {
// Page furniture and decoration.
let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other };
s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None)));
self.item(s, it, parent, page);
s.end_tagged();
return;
}
if let Some(sid) = story {
// Text runs tag themselves by paragraph; the rest of the frame is an artifact.
if let std::collections::hash_map::Entry::Vacant(e) = self.story_tags.entry(sid) {
e.insert(Vec::new());
self.tags.push(TagEntry::Story(sid));
}
self.tag_story = Some(sid);
self.item(s, it, parent, page);
self.tag_story = None;
return;
}
let id = s.start_tagged(ContentTag::Other);
self.item(s, it, parent, page);
s.end_tagged();
let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone());
let mut g = TagGroup::new(Tag::Figure(alt));
g.push(id);
self.tags.push(TagEntry::Figure(g));
}
/// The structure tree: stories (one paragraph each, its frames in thread order of appearance)
/// and figures, in the order they first appear.
fn tag_tree(&mut self) -> TagTree {
let mut tree = TagTree::new();
for e in std::mem::take(&mut self.tags) {
match e {
TagEntry::Story(sid) => {
// One structure element per paragraph: headings and quotes from the
// paragraph style's Export Tagging, else P.
let mut paras: Vec<usize> = self.para_tags.keys().filter(|(s, _)| *s == sid).map(|(_, p)| *p).collect();
paras.sort_unstable();
let story = self.doc.story(sid);
for pi in paras {
let ids = self.para_tags.remove(&(sid, pi)).unwrap_or_default();
let style = story.and_then(|st| st.paras.get(pi)).map(|p| p.style.clone()).unwrap_or_default();
let tag = self.doc.styles.export_tag(&style, false).map(|e| e.tag.clone()).unwrap_or_default();
let text = || story.and_then(|st| st.para_ranges().get(pi).map(|r| st.text[r.clone()].trim().to_string()));
let kind: krilla::tagging::TagKind = match tag.as_str() {
h if h.len() == 2 && h.starts_with('h') => {
let n = h[1..].parse::<u16>().unwrap_or(1).clamp(1, 6);
Tag::Hn(std::num::NonZeroU16::new(n).unwrap_or(std::num::NonZeroU16::MIN), text()).into()
}
"blockquote" => Tag::BlockQuote.into(),
_ => Tag::P.into(),
};
let mut g = TagGroup::new(kind);
for id in ids {
g.push(id);
}
tree.push(g);
}
}
TagEntry::Figure(g) => tree.push(g),
}
}
tree
}
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
let gf = &it.effects.gradient_feather;
if !gf.on || it.hidden || it.nonprinting {
return self.item_inner(s, it, parent, page);
}
// Gradient feather: a luminosity mask (white = opaque) over the object.
let xf = parent * it.xf;
let (p0, p1) = gf.points(it.feather_bounds());
let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() };
let stops = vec![
Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE },
Stop { offset: NormalizedF32::ONE, color: gray(gf.end), opacity: NormalizedF32::ONE },
];
let paint: krilla::paint::Paint = if gf.radial {
let r = (p1 - p0).hypot().max(1e-3) as f32;
RadialGradient {
fx: p0.x as f32,
fy: p0.y as f32,
fr: 0.0,
cx: p0.x as f32,
cy: p0.y as f32,
cr: r,
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
} else {
LinearGradient {
x1: p0.x as f32,
y1: p0.y as f32,
x2: p1.x as f32,
y2: p1.y as f32,
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
};
// The gradient keeps its item-space geometry, but its mask covers the complete exported
// area (including bleed), so distant child shadows and thick strokes are never cut off.
let mut sb = s.stream_builder();
let mut ms = sb.surface();
if let Some(p) = to_path(&self.clip.to_path(0.1)) {
ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
ms.draw_path(&p);
}
ms.finish();
let stream = sb.finish();
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
self.item_inner(s, it, parent, page);
s.pop();
}
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
if it.hidden || it.nonprinting {
return;
}
let ds = &it.effects.drop_shadow;
let grow = it.stroke.extent() + 2.0 + if ds.on { ds.distance.abs() + ds.size.abs() } else { 0.0 };
let vb = parent.transform_rect_bbox(it.bounds()).inflate(grow, grow);
if !rects_overlap(vb, self.clip) {
return;
}
let xf = parent * it.xf;
if it.has_nested_items() {
// A frame with items pasted into it: fill, the items clipped to the frame, stroke.
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let path = to_path(&bp);
let pushes = Self::push_group(s, it.opacity, it.blend);
s.push_transform(&tf(xf));
if !it.fill.is_none()
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
}
s.pop();
// The clip in page space, so the children keep their absolute transforms.
let mut page_bp = bp.clone();
page_bp.apply_affine(xf);
let clip = to_path(&page_bp);
if let Some(c) = &clip {
s.push_clip_path(c, &FillRule::NonZero);
}
for c in it.children() {
self.item(s, c, xf, page);
}
if clip.is_some() {
s.pop();
}
if !it.stroke.is_none()
&& let Some(p) = &path
{
s.push_transform(&tf(xf));
self.stroke(s, it, &bp, p);
s.pop();
}
for _ in 0..pushes {
s.pop();
}
return;
}
if !it.children().is_empty() {
let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it);
for c in it.shown_children() {
self.item(s, c, xf, page);
}
for _ in 0..pushes {
s.pop();
}
return;
}
let doc = self.doc;
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let path = to_path(&bp);
let pushes = Self::push_group(s, it.opacity, it.blend);
let frame_art = self.tag_story.is_some();
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
// Drop shadow (simple offset silhouette, like the renderer).
if ds.on
&& let Some(p) = &path
&& let Some(c) = self.swatch_color(&ds.color, 1.0)
{
let a = self.doc.light_angle(ds.angle, ds.global_light).to_radians();
let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance);
s.push_transform(&tf(Affine::translate(off) * xf));
s.set_stroke(None);
s.set_fill(Some(solid_fill(c, ds.opacity)));
s.draw_path(p);
s.set_fill(None);
s.pop();
}
s.push_transform(&tf(xf));
// Fill.
if !it.fill.is_none()
&& it.path.is_closed()
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
if it.fill.overprint {
self.warn("overprint is not exported yet");
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
}
if frame_art {
s.end_tagged();
}
// Content.
match &it.content {
Content::Graphic(g) => {
let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
if let Some(p) = &path {
s.push_clip_path(p, &FillRule::NonZero);
self.graphic(s, g);
s.pop();
}
}
Content::Text(tfr) => {
let cs = self.cache.get(doc, tfr.story, page);
if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) {
// Type on a path: the glyphs as outlines along the path.
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
let st = &cs.styles[si as usize];
if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) {
s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&p);
s.set_fill(None);
}
}
} else if let Some(ft) = cs.frame(it.id) {
let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or("");
let local = doc.text_local(it);
if local != Affine::IDENTITY {
s.push_transform(&tf(local));
}
self.frame_text(s, &cs, ft, text);
if local != Affine::IDENTITY {
s.pop();
}
}
}
_ => {}
}
// Stroke.
if !it.stroke.is_none()
&& let Some(p) = &path
{
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
self.stroke(s, it, &bp, p);
if frame_art {
s.end_tagged();
}
}
s.pop();
for _ in 0..pushes {
s.pop();
}
// Anchored objects in the frame's text.
if let Content::Text(tfr) = &it.content
&& let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty())
{
let cs = self.cache.get(doc, tfr.story, page);
if let Some(ft) = cs.frame(it.id) {
for o in &ft.objects {
if let Some(obj) = st.objects.get(o.index) {
self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page);
}
}
}
}
}
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
let kind = g.kind;
let mut expanded = g.expanded_stops();
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
for (_, c, _) in &mut expanded {
if let Color::Gray { k } = *c {
*c = Color::cmyk(0.0, 0.0, 0.0, k);
}
}
}
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
let components = |c: &Color| match c {
Color::Cmyk { .. } if !self.rgb_only => 4,
Color::Gray { .. } => 1,
_ => 3,
};
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
if mixed {
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
}
let mut stops: Vec<Stop> = Vec::new();
let mut last = 0.0f32;
for (o, c, a) in expanded {
let o = o.clamp(last, 1.0);
last = o;
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
let [r, g, b] = c.to_rgb();
rgb::Color::new(q(r), q(g), q(b)).into()
} else {
// Preserve RGB values, including already-interpolated display RGB, and
// leave every homogeneous gradient on its existing device-space path.
device(&c, self.rgb_only)
};
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
if stops.is_empty() {
return None;
}
let c = bounds.center();
let v = fill.gradient_vector;
return Some(match kind {
GradientKind::Radial => {
// A circle stretched into the object's ellipse, as on screen.
let (c, rx, ry) = fill.radial_ellipse(bounds);
let rx = rx.max(1e-3);
let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2());
let (cx, cy) = (c.x as f32, c.y as f32);
RadialGradient {
fx: cx,
fy: cy,
fr: 0.0,
cx,
cy,
cr: rx as f32,
transform: tf(stretch),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
}
_ => {
let a = angle.unwrap_or(0.0).to_radians();
let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3);
let d = Vec2::new(a.cos(), -a.sin()) * half;
let (p0, p1) = match v {
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)),
None => (c - d, c + d),
};
LinearGradient {
x1: p0.x as f32,
y1: p0.y as f32,
x2: p1.x as f32,
y2: p1.y as f32,
transform: Transform::identity(),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
}
});
}
self.swatch_color(swatch, tint).map(Into::into)
}
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
let st = &it.stroke;
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
let closed = it.path.is_closed();
let mut cap = match st.cap {
designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,
designcraft_doc::Cap::Round => krilla::paint::LineCap::Round,
designcraft_doc::Cap::Projecting => krilla::paint::LineCap::Square,
};
let kind = self.doc.stroke_kind(&st.kind);
// Stripes, wavy and hash strokes: a filled outline (gap colour under it).
if let Some(o) = kind.outline(bp, st.weight, 0.05)
&& let Some(op) = to_path(&o)
{
s.set_stroke(None);
if let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
&& st.gap_swatch != designcraft_color::swatch::NONE
{
s.set_stroke(Some(Stroke { paint: g.into(), width: st.weight as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.set_fill(None);
s.draw_path(path);
s.set_stroke(None);
}
s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&op);
s.set_fill(None);
return;
}
let dash = match &kind {
StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
Some(StrokeDash { array: pat, offset: 0.0 })
}
StrokeType::Dotted => {
cap = krilla::paint::LineCap::Round;
Some(StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 })
}
_ => None,
};
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
let trimmed = arrows.as_ref().and_then(|a| to_path(&a.0));
let path = trimmed.as_ref().unwrap_or(path);
let aligned = closed && st.align != StrokeAlign::Center;
let width = if aligned { st.weight * 2.0 } else { st.weight };
let mut pushes = 0;
match st.align {
StrokeAlign::Inside if closed => {
s.push_clip_path(path, &FillRule::NonZero);
pushes += 1;
}
StrokeAlign::Outside if closed => {
// Clip to everything outside the path: a big frame plus the path, even-odd.
let b = bp.bounding_box().inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
let mut outside = b.to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(&outside) {
s.push_clip_path(&p, &FillRule::EvenOdd);
pushes += 1;
}
}
_ => {}
}
s.set_fill(None);
if dash.is_some()
&& pushes == 0
&& let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
{
// Gap colour under dashes and dots.
s.set_stroke(Some(Stroke { paint: g.into(), width: width as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.draw_path(path);
}
s.set_stroke(Some(Stroke {
paint: c.clone().into(),
width: width as f32,
miter_limit: st.miter_limit.max(1.0) as f32,
line_cap: cap,
line_join: match st.join {
designcraft_doc::Join::Miter => krilla::paint::LineJoin::Miter,
designcraft_doc::Join::Round => krilla::paint::LineJoin::Round,
designcraft_doc::Join::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity: NormalizedF32::ONE,
dash,
}));
s.draw_path(path);
s.set_stroke(None);
for _ in 0..pushes {
s.pop();
}
for h in arrows.iter().flat_map(|a| &a.1) {
let Some(p) = to_path(&h.path) else { continue };
match h.outline {
Some(w) => {
s.set_stroke(Some(Stroke { paint: c.clone().into(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.draw_path(&p);
s.set_stroke(None);
}
None => {
s.set_fill(Some(krilla::paint::Fill { paint: c.clone().into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&p);
s.set_fill(None);
}
}
}
}
/// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the
/// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while
/// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7
/// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<krilla::pdf::PdfDocument> {
use hayro_syntax::PdfVersion as V;
let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else {
self.warn(format!("{}: can't read the placed PDF", asset.name));
return None;
};
let max = match self.version {
PdfVersion::Pdf14 => V::Pdf14,
PdfVersion::Pdf15 => V::Pdf15,
PdfVersion::Pdf16 => V::Pdf16,
PdfVersion::Pdf17 => V::Pdf17,
PdfVersion::Pdf20 => V::Pdf20,
};
let have = pdf.version();
if have <= max {
return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf)));
}
if have == V::Pdf17
&& max == V::Pdf16
&& let Some(bytes) = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6")
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(bytes))
&& pdf.version() <= max
{
self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name));
return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf)));
}
let v = match have {
V::Pdf17 => "PDF 1.7",
V::Pdf20 => "PDF 2.0",
_ => "a newer PDF",
};
self.too_new.push(format!("{} is {v}", asset.name));
None
}
/// Fail with the names of placed PDFs too new for this export (see [`Exporter::placed_pdf`]).
fn check_placed_versions(&self) -> Result<()> {
if self.too_new.is_empty() {
return Ok(());
}
let v = self.version.as_str();
Err(PdfError::Write(format!(
"a placed PDF is newer than the {v} this export writes ({}): save it as {v} or older and place it again",
self.too_new.join(", ")
)))
}
fn load_image(&mut self, id: AssetId) -> Option<Image> {
if let Some(i) = self.images.get(&id) {
return i.clone();
}
let asset = self.doc.assets.get(&id)?.clone();
let data = asset.data.clone();
if let Some(converted) = self.images_by_content.get(&data).cloned() {
if let Some(warning) = converted.warning {
self.warn(format!("{}: {warning}", asset.name));
}
self.images.insert(id, Some(converted.image.clone()));
return Some(converted.image);
}
let fmt = image::guess_format(&data).ok();
#[cfg(test)]
{
self.image_conversions += 1;
}
let mut warning = None;
let mut mode = RasterMode::for_standard(self.opts.standard);
mode.interpolate &= self.interpolate;
let img = match fmt {
Some(image::ImageFormat::Jpeg) => jpeg(data.clone().into(), || embedded_icc(&data), mode),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
// as four-colour black). PDF/A exports are RGB only.
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) {
Some(img) => Some(img),
None => {
warning = Some(CMYK_FALLBACK_WARNING);
self.warn(format!("{}: {CMYK_FALLBACK_WARNING}", asset.name));
lossless(&data, fmt, mode)
}
},
_ if self.opts.compress_images => recompress(&data, mode).or_else(|| lossless(&data, fmt, mode)),
_ => lossless(&data, fmt, mode),
};
if img.is_none() {
if data.is_empty() {
// A linked file that wasn't found when the document was opened: nothing to decode.
let at = asset.link.as_deref().map(|l| format!(" ({l})")).unwrap_or_default();
self.warn(format!("image `{}` is missing{at} and was skipped", asset.name));
} else {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
}
}
if let Some(image) = &img {
// Share the original encoded payload. HashMap checks exact bytes after hashing,
// so collisions cannot merge distinct images or their diagnostics.
self.images_by_content.insert(data, ConvertedImage { image: image.clone(), warning });
}
self.images.insert(id, img.clone());
img
}
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
// Video / sound without a poster: a dark frame with a play mark.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_doc::media_kind(&asset.mime).is_some()
{
let (w, h) = g.size;
let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h) * 0.18);
let mut tri = BezPath::new();
tri.move_to((cx - r * 0.6, cy - r));
tri.line_to((cx + r, cy));
tri.line_to((cx - r * 0.6, cy + r));
tri.close_path();
s.push_transform(&tf(g.xf));
s.set_stroke(None);
for (bp, c) in [(Rect::new(0.0, 0.0, w, h).to_path(0.1), rgb::Color::new(48, 48, 52)), (tri, rgb::Color::new(235, 235, 235))] {
if let Some(p) = to_path(&bp) {
s.set_fill(Some(Fill { paint: c.into(), ..Default::default() }));
s.draw_path(&p);
}
}
s.set_fill(None);
s.pop();
return;
}
// Placed PDFs go in as vectors (the page as a form XObject).
let doc = self.doc;
if let Some(asset) = doc.assets.get(&g.asset)
&& asset.data.starts_with(b"%PDF")
{
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
let placed = match self.pdfs.get(&g.asset) {
Some(p) => p.clone(),
None => {
let p = self.placed_pdf(asset);
self.pdfs.insert(g.asset, p.clone());
p
}
};
if let Some(placed) = placed {
s.push_transform(&tf(g.xf));
s.draw_pdf_page(&placed, size, asset.page as usize);
s.pop();
}
return;
}
// Placed SVGs go in as vectors too.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_images::is_svg(&asset.data)
{
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
let tree = self.svgs.entry(g.asset).or_insert_with(|| designcraft_images::svg_tree(&asset.data).map(Arc::new)).clone();
match tree {
Some(tree) => {
use krilla_svg::SurfaceExt;
s.push_transform(&tf(g.xf));
if s.draw_svg(&tree, size, krilla_svg::SvgSettings::default()).is_none() {
self.warn(format!("{}: parts of the placed SVG could not be drawn", asset.name));
}
s.pop();
}
None => self.warn(format!("{}: can't read the placed SVG", asset.name)),
}
return;
}
let Some(img) = self.load_image(g.asset) else { return };
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
s.push_transform(&tf(g.xf));
s.draw_image(img, size);
s.pop();
}
}
/// `data` with its `%PDF-x.y` header (within the first 1024 bytes, where readers look for it)
/// changed from `from` to `to`, both the same length. `None` when the header isn't `from`.
fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
if from.len() != to.len() {
return None;
}
let head = data.get(..data.len().min(1024))?;
let at = head.windows(from.len()).position(|w| w == from)?;
let mut out = data.to_vec();
out.get_mut(at..at + to.len())?.copy_from_slice(to);
Some(out)
}
/// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4.
pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc");
/// How raster images are written for the export's standard.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RasterMode {
/// `/Interpolate true`: smoother upscaling in viewers. PDF/X-4 and PDF/A don't allow it.
interpolate: bool,
/// Give RGB images with no ICC profile of their own the sRGB profile: under PDF/X-4's CMYK
/// output intent, RGB must be colour-managed.
tag_rgb: bool,
}
impl RasterMode {
fn for_standard(standard: Standard) -> Self {
Self { interpolate: standard == Standard::None, tag_rgb: standard == Standard::PdfX4 }
}
}
/// The ICC profile embedded in an image file, if it has one.
fn embedded_icc(data: &[u8]) -> Option<Vec<u8>> {
use image::ImageDecoder;
let mut dec = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_decoder().ok()?;
dec.icc_profile().ok().flatten()
}
/// Whether an ICC profile describes RGB (its colour space signature, header bytes 16 to 20).
fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option<Vec<u8>>, mode: RasterMode) -> Option<Image> {
if !mode.tag_rgb {
return Image::from_jpeg(data, mode.interpolate).ok();
}
let icc: krilla::Data = match icc() {
Some(own) => own.into(),
None => SRGB_ICC.into(),
};
Image::from_jpeg_with_icc(data, Some(icc), mode.interpolate).ok()
}
/// Whether krilla would write this PNG, GIF or WebP as untagged RGB: not grey, and no profile of
/// its own.
fn untagged_rgb(data: &[u8]) -> bool {
use image::ImageDecoder;
let Some(mut dec) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok()) else {
return false;
};
let grey = matches!(dec.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16);
!grey && dec.icc_profile().ok().flatten().is_none()
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMode) -> Option<Image> {
let i = mode.interpolate;
// krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole
// export when the PDF is written. Decoding it first means it is skipped, like any other
// image that can't be decoded.
let rgba = designcraft_images::decode_rgba(data)?;
let direct = match fmt {
Some(image::ImageFormat::Png | image::ImageFormat::Gif | image::ImageFormat::WebP) if mode.tag_rgb && untagged_rgb(data) => None,
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), i).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), i).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), i).ok(),
_ => None,
};
direct.or_else(|| {
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
}
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
}
/// An RGB raster tagged with the sRGB profile, for PDF/X-4.
#[derive(Clone, Hash)]
struct SrgbImage {
rgb: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
}
impl SrgbImage {
fn new(rgba: image::RgbaImage) -> Option<Self> {
let size = rgba.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
let mut rgb = Vec::with_capacity(pixels.checked_mul(3)?);
let mut alpha = Vec::with_capacity(pixels);
for p in rgba.pixels() {
rgb.extend_from_slice(&p.0[..3]);
alpha.push(p.0[3]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
Some(Self { rgb: Arc::new(rgb), alpha, size })
}
}
impl krilla::image::CustomImage for SrgbImage {
fn color_channel(&self) -> &[u8] {
&self.rgb
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.alpha.as_deref().map(Vec::as_slice)
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
Some(SRGB_ICC)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Rgb
}
}
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
/// convert 100% K to four-colour black.
#[derive(Clone, Hash)]
struct CmykImage(Arc<designcraft_images::CmykRaster>);
impl krilla::image::CustomImage for CmykImage {
fn color_channel(&self) -> &[u8] {
&self.0.cmyk
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.0.alpha.as_deref()
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
(self.0.width, self.0.height)
}
fn icc_profile(&self) -> Option<&[u8]> {
None
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Cmyk
}
}
/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK.
fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option<Image> {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok()
}
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
let img = image::load_from_memory(data).ok()?;
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
}
let rgb = img.to_rgb8();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
}
#[cfg(test)]
mod image_content_cache_tests {
use super::*;
use designcraft_doc::Asset;
fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(w, h, image::Rgba(color));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
fn asset(doc: &mut Document, bytes: Vec<u8>, name: &str) -> AssetId {
let id = AssetId(doc.alloc());
doc.assets.insert(
id,
Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(bytes), pixels: None, page: 0 }),
);
id
}
fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> {
Exporter {
doc,
cache,
opts,
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
image_conversions: 0,
pdfs: HashMap::new(),
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
interpolate: true,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
para_tags: HashMap::new(),
}
}
#[test]
fn exact_bytes_reuse_conversion_under_real_hash_collisions() {
use std::hash::{BuildHasher, Hasher};
let mut d = Document::new(&Default::default());
let red = png(2, 1, [255, 0, 0, 255]);
let blue = png(1, 2, [0, 0, 255, 128]);
let a1 = asset(&mut d, red.clone(), "a1");
let b1 = asset(&mut d, blue.clone(), "b1");
let a2 = asset(&mut d, red, "a2");
let b2 = asset(&mut d, blue, "b2");
assert!(!Arc::ptr_eq(&d.assets[&a1].data, &d.assets[&a2].data));
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
let mut h1 = ex.images_by_content.hasher().build_hasher();
h1.write(&d.assets[&a1].data);
let mut h2 = ex.images_by_content.hasher().build_hasher();
h2.write(&d.assets[&b1].data);
assert_eq!(h1.finish(), h2.finish());
assert_ne!(d.assets[&a1].data, d.assets[&b1].data);
for (id, count, size) in [(a1, 1, (2, 1)), (b1, 2, (1, 2)), (a2, 2, (2, 1)), (a1, 2, (2, 1)), (b2, 2, (1, 2))] {
assert_eq!(ex.load_image(id).unwrap().size(), size);
assert_eq!(ex.image_conversions, count, "count actual converter executions, not krilla resource equality");
}
assert_eq!(ex.images.len(), 4);
assert_eq!(ex.images_by_content.len(), 2);
let first_key = ex.images_by_content.keys().find(|key| key.as_slice() == d.assets[&a1].data.as_slice()).unwrap();
assert!(Arc::ptr_eq(first_key, &d.assets[&a1].data), "cache keys share the original encoded payload");
ex.images_by_content.clear();
assert_eq!(ex.load_image(a1).unwrap().size(), (2, 1));
assert_eq!(ex.image_conversions, 2, "the AssetId cache remains the first lookup");
assert!(ex.images_by_content.is_empty());
}
#[test]
fn successful_content_cache_does_not_suppress_failure_names() {
let mut d = Document::new(&Default::default());
let a = asset(&mut d, vec![0, 1, 2, 3], "broken-a.png");
let b = asset(&mut d, vec![0, 1, 2, 3], "broken-b.png");
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_none());
assert!(ex.load_image(b).is_none());
assert!(ex.load_image(a).is_none());
assert_eq!(ex.image_conversions, 2);
assert!(ex.images_by_content.is_empty());
assert_eq!(ex.warnings.len(), 2);
assert!(ex.warnings[0].contains("broken-a.png"));
assert!(ex.warnings[1].contains("broken-b.png"));
}
#[test]
fn content_cache_is_scoped_to_each_immutable_options_context() {
let mut d = Document::new(&Default::default());
let data = png(2, 2, [240, 20, 30, 255]);
let a = asset(&mut d, data.clone(), "a");
let b = asset(&mut d, data, "b");
let cache = Cache::new();
for compress_images in [false, true, false] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
}
}
#[test]
fn successful_cmyk_fallback_replays_each_asset_name_once_and_converts_once() {
let bytes = crate::image_fixtures::cmyk_tiff(None, true);
assert!(designcraft_images::is_cmyk_tiff(&bytes));
assert!(designcraft_images::decode_cmyk_tiff(&bytes).is_none());
assert!(designcraft_images::decode_rgba(&bytes).is_some());
let mut d = Document::new(&Default::default());
let a = asset(&mut d, bytes.clone(), "ink-a.tif");
let b = asset(&mut d, bytes, "ink-b.tif");
let cache = Cache::new();
for compress_images in [false, true] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
for id in [a, b, a, b] {
assert_eq!(ex.load_image(id).unwrap().size(), (2, 1));
}
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
assert_eq!(ex.warnings, [format!("ink-a.tif: {CMYK_FALLBACK_WARNING}"), format!("ink-b.tif: {CMYK_FALLBACK_WARNING}")]);
}
}
#[test]
fn cmyk_alpha_and_rgb_only_context_keep_conversion_counts_and_failures() {
let cache = Cache::new();
for alpha in [None, Some(2), Some(1)] {
let mut d = Document::new(&Default::default());
let bytes = crate::image_fixtures::cmyk_tiff(alpha, false);
let a = asset(&mut d, bytes.clone(), "a.tif");
let b = asset(&mut d, bytes, "b.tif");
for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX4, Standard::PdfA2b, Standard::None] {
let opts = PdfOptions { standard, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
ex.rgb_only = standard == Standard::PdfA2b;
// The pinned image decoder does not support CMYKA as RGB. Associated alpha
// is also rejected by the ink-preserving path: retain both failure names.
let succeeds = alpha.is_none() || (alpha == Some(2) && !ex.rgb_only);
let first = ex.load_image(a);
assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}");
if alpha.is_none() {
let (space, samples) = image_color_samples(first.unwrap());
assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" });
assert_eq!(samples, if ex.rgb_only { vec![0, 0, 0, 255, 0, 0] } else { crate::image_fixtures::INKS.to_vec() });
}
assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}");
assert_eq!(ex.load_image(a).is_some(), succeeds);
assert_eq!(ex.image_conversions, if succeeds { 1 } else { 2 });
assert_eq!(ex.images_by_content.len(), usize::from(succeeds));
if succeeds {
assert!(ex.warnings.is_empty());
} else {
for name in ["a.tif", "b.tif"] {
assert!(ex.warnings.contains(&format!("image `{name}` could not be decoded and was skipped")));
assert_eq!(ex.warnings.contains(&format!("{name}: {CMYK_FALLBACK_WARNING}")), !ex.rgb_only);
}
}
}
}
}
// Serialize the converted raster without an archival validator so the RGB-only pixel
// contract stays independent of main's existing PDF/A interpolation validation failure.
fn image_color_samples(image: Image) -> (String, Vec<u8>) {
use hayro_syntax::object::Name;
let mut pdf = krilla::Document::new();
let size = Size::from_wh(20.0, 20.0).unwrap();
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut surface = page.surface();
surface.draw_image(image, size);
surface.finish();
page.finish();
let bytes = pdf.finish().unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let stream = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.find(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.unwrap();
(stream.dict().get::<Name>("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned())
}
#[test]
fn content_cache_owns_only_existing_payloads_and_drops_with_exporter() {
let mut d = Document::new(&Default::default());
let bytes = png(2, 1, [20, 30, 40, 255]);
let a = asset(&mut d, bytes.clone(), "a.png");
let b = asset(&mut d, bytes, "b.png");
let payload = Arc::downgrade(&d.assets[&a].data);
let duplicate = Arc::downgrade(&d.assets[&b].data);
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(duplicate.strong_count(), 1, "a content hit must not retain the second encoded allocation");
let key = ex.images_by_content.keys().next().unwrap();
assert!(Arc::ptr_eq(key, &d.assets[&a].data));
drop(ex);
drop(d);
assert!(payload.upgrade().is_none(), "no converted image or encoded key outlives this exporter");
assert!(duplicate.upgrade().is_none());
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod unwind_tests {
use super::*;
use std::cell::Cell;
thread_local! {
static FAIL_NEXT_SURFACE: Cell<bool> = const { Cell::new(false) };
}
// Fail with live backend drawing state, so a missing guard or an unpatched destructor
// makes the child fail. Thread-local state keeps unrelated export tests independent.
#[allow(clippy::panic)] // Test-only fault injection exercises recovery with a live surface.
pub(super) fn inject(surface: &mut krilla::surface::Surface<'_>) {
if FAIL_NEXT_SURFACE.replace(false) {
surface.push_transform(&krilla::geom::Transform::from_translate(2.0, 3.0));
panic!("synthetic export drawing failure");
}
}
#[test]
fn export_entry_points_recover_in_a_subprocess() {
const CHILD: &str = "DESIGNCRAFT_PDF_EXPORT_PANIC_PROBE";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "export::unwind_tests::export_entry_points_recover_in_a_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success(), "export child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("all three export entry points recovered"));
return;
}
let doc = Document::new(&designcraft_doc::build::NewDocument { pages: 1, ..Default::default() });
let cache = Cache::new();
let source = export_pdf(&doc, &cache, &Default::default()).unwrap();
let parts = [(source, vec![(612.0, 792.0)])];
for entry in ["document", "booklet", "merge"] {
let export = || match entry {
"document" => export_pdf(&doc, &cache, &Default::default()),
"booklet" => export_booklet(&doc, &cache, &Default::default()).map(|report| report.bytes),
_ => merge_pdfs(&parts, None),
};
FAIL_NEXT_SURFACE.set(true);
assert!(matches!(export(), Err(PdfError::Write(message)) if message.contains("synthetic export drawing failure")), "{entry}");
let fresh = export().unwrap();
assert!(!hayro_syntax::Pdf::new(Arc::new(fresh)).unwrap().pages().is_empty(), "fresh {entry} export must still work");
}
println!("all three export entry points recovered");
}
}