Files
designcraft/crates/pdf/src/export.rs
T

2219 lines
95 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, SpreadRef, 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;
/// Whether a layer's items go into the PDF.
pub(crate) fn layer_exported(layer: &designcraft_doc::Layer) -> bool {
layer.visible && layer.printable
}
/// Whether an item and everything inside it go into the PDF, its layer aside.
pub(crate) fn item_exported(it: &Item) -> bool {
!it.hidden && !it.nonprinting
}
/// Every item of spread `si` that goes into the PDF, with its parent's transform: the items on
/// exported layers, leaving out hidden and non-printing items with everything inside them.
pub(crate) fn exported_items(doc: &Document, si: usize) -> Vec<(Affine, &Item)> {
let Some(sp) = doc.spreads.get(si) else { return Vec::new() };
let mut stack: Vec<(Affine, &Item)> =
sp.items.iter().filter(|it| doc.layer(it.layer).is_some_and(layer_exported)).map(|it| (Affine::IDENTITY, &**it)).collect();
let mut out = Vec::new();
while let Some((xf, it)) = stack.pop() {
if !item_exported(it) {
continue;
}
stack.extend(it.shown_children().map(|c| (xf * it.xf, &**c)));
out.push((xf, it));
}
out
}
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);
// The outer edges by position: a right-to-left spread lists its right page first.
let outer = sp.outer_page_indices();
let (l, _) = outer.and_then(|(l, _)| sp.pages.get(l)).map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
let (_, r) = outer.and_then(|(_, r)| sp.pages.get(r)).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))
}
/// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
/// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
/// Screen annotations aren't allowed in PDF/A.
fn add_form_fields(bytes: &mut Vec<u8>, warnings: &mut Vec<String>, doc: &Document, sheets: &[Sheet], opts: &PdfOptions) {
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() {
return;
}
match opts.standard {
Standard::PdfX4 => warnings.push("PDF/X-4: form fields and media were left out".into()),
Standard::PdfA2b => warnings.push("PDF/A-2b: form fields and media were left out".into()),
_ => 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()),
},
}
}
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 {
drawing_items: Vec::new(),
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(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: 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);
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title, created) {
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 {
drawing_items: Vec::new(),
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,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: 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. A time
/// outside chrono's year range still converts (the calendar repeats every 400 years, so the
/// instant is moved into range by whole cycles and only the year is carried, matching the old
/// `civil`), rather than resetting to the epoch. `date_time` applies the PDF `0..=9999` year clamp.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
use chrono::{Datelike, Timelike};
let (y, t) = designcraft_doc::time::utc_parts(t, 0);
(y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() 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>,
drawing_items: Vec<designcraft_doc::ItemId>,
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) and their page counts.
pdfs: HashMap<AssetId, Option<(krilla::pdf::PdfDocument, usize)>>,
/// 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,
/// 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>,
/// While drawing a frame's text: the frame's box in text space, which a gradient on the text
/// spans (as InDesign lays it across the frame).
pub(crate) text_area: Option<Rect>,
/// 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))
}
/// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases
/// first, then the parent.
fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) {
let doc = self.doc;
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.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);
}
}
}
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_exported(layer) {
continue;
}
for (pi, page) in sp.pages.iter().enumerate() {
if page.show_parent_items {
self.parent_items(s, si, pi, page, layer, first);
}
}
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 || !item_exported(it) {
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>) {
if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) {
self.warnings.push(format!("Cyclic or excessively nested item {} was not rendered", it.id));
return;
}
self.drawing_items.push(it.id);
self.item_guarded(s, it, parent, page);
self.drawing_items.pop();
}
fn item_guarded(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
let gf = &it.effects.gradient_feather;
if !gf.on || !item_exported(it) {
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 !item_exported(it) {
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()
&& 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];
// A gradient spans the path's box.
let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() };
if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) {
s.set_fill(Some(Fill { paint, 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.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds()));
self.frame_text(s, &cs, ft, text);
self.text_area = None;
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();
}
if let Content::Text(tfr) = &it.content {
let cs = self.cache.get(doc, tfr.story, page);
if let Some(ft) = cs.frame(it.id) {
self.frame_objects(s, ft, xf * doc.text_local(it), page);
}
}
}
/// Draw embedded page items after restoring the surface, retaining spread-space
/// transforms for page culling (also for anonymous note and cell stories).
fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option<usize>) {
let mut pending = vec![(ft, xf)];
while let Some((frame, transform)) = pending.pop() {
for object in &frame.objects {
self.item(s, &object.object.item, transform * Affine::translate(object.origin.to_vec2()), page);
}
for note in &frame.notes {
if let Some(frame) = note.text.frames.first() {
pending.push((frame, transform * Affine::translate(note.origin.to_vec2())));
}
}
for cell in frame.tables.iter().flat_map(|t| &t.cells) {
if let Some(frame) = cell.text.frames.first() {
pending.push((frame, transform * Affine::translate(cell.origin.to_vec2())));
}
}
}
}
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
self.fill_paint_in(fill, bounds, Affine::IDENTITY)
}
/// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one
/// (text runs: the frame's text space under the run's origin).
pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option<krilla::paint::Paint> {
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
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;
}
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(xf * stretch),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
}
_ => {
let (p0, p1) = fill.linear_axis(bounds);
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()
}
});
}
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;
// A gradient swatch runs across the path's box, as on a fill.
let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() {
let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return };
p
} else {
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
c.into()
};
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: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&op);
s.set_fill(None);
return;
}
// Viewers expand dashes too: a degenerate pattern draws solid, as on screen.
let length = designcraft_doc::path_length_bound(bp);
let dash = match &kind {
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {
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 if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => {
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: paint.clone(),
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: paint.clone(), 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: paint.clone(), 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 and catalog `/Version` relabelled, as
/// `qpdf --force-version=1.6` does). A newer
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
/// Returns the document with its page count.
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> {
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 {
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
if have == V::Pdf17
&& max == V::Pdf16
&& let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec())
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(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));
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
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();
// Use the same EPS preview as the renderer, preserving CMYK TIFF inks below. The
// content cache is keyed on the bytes actually converted, so equal previews share one.
let data = if designcraft_images::is_eps(&asset.data) {
let Some((proxy, _)) = designcraft_images::eps_proxy(&asset.data) else {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
self.images.insert(id, None);
return None;
};
Arc::new(proxy)
} else {
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 mode = RasterMode::for_standard(self.opts.standard);
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
}
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
match g.clip_region().map(|(region, even_odd)| (to_path(&region), even_odd)) {
Some((Some(clip), even_odd)) => {
s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero });
self.graphic_unclipped(s, g);
s.pop();
}
// A degenerate outline shows nothing, or everything when inverted.
Some((None, even_odd)) => {
if even_odd {
self.graphic_unclipped(s, g);
}
}
None => self.graphic_unclipped(s, g),
}
}
fn graphic_unclipped(&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")
{
// Cropped to a box: the whole page, placed so that the box spans graphic space.
let shown = asset.shown_box();
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.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
}
};
match placed {
// krilla would fail the whole export at the end on a page the PDF doesn't have.
Some((_, pages)) if asset.page as usize >= pages => {
self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1));
}
Some((placed, _)) => {
s.push_transform(&tf(g.xf));
let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1)));
if let Some(clip) = &clip {
s.push_clip_path(clip, &FillRule::NonZero);
}
s.push_transform(&tf(Affine::translate((-l * page_w, -t * page_h))));
s.draw_pdf_page(&placed, size, asset.page as usize);
s.pop();
if clip.is_some() {
s.pop();
}
s.pop();
}
None => {}
}
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;
}
if self.colorized(s, g) {
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();
}
/// A greyscale or bitmap image coloured by the swatch applied to it (#1094): the swatch is
/// painted through the image's ink coverage (its darkness), over 0% ink for a greyscale
/// image, whose white is opaque. `false`, drawing nothing, when the image keeps its own
/// colours: no swatch, one that no longer exists, a colour image.
fn colorized(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) -> bool {
if g.fill.is_none() {
return false;
}
let Some(asset) = self.doc.assets.get(&g.asset) else { return false };
let Some(tone) = designcraft_images::tone(&asset.data) else { return false };
let Some(paint) = self.swatch_color(&g.fill.swatch, g.fill.tint) else { return false };
let Some(coverage) = designcraft_images::decode_rgba(&asset.data).and_then(|img| CoverageImage::new(&img)) else { return false };
let interpolate = RasterMode::for_standard(self.opts.standard).interpolate;
let Ok(mask) = Image::from_custom(coverage, interpolate) else { return false };
let size = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32);
let (Some(size), Some(rect)) = (size, to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))) else { return false };
s.push_transform(&tf(g.xf));
if tone == designcraft_images::Tone::Gray {
let blank =
self.swatch_color(designcraft_color::swatch::PAPER, 1.0).unwrap_or_else(|| device(&Color::cmyk(0.0, 0.0, 0.0, 0.0), self.rgb_only));
s.set_fill(Some(Fill { paint: blank.into(), ..Default::default() }));
s.draw_path(&rect);
}
let mut sb = s.stream_builder();
let mut ms = sb.surface();
ms.draw_image(mask, size);
ms.finish();
let stream = sb.finish();
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
s.set_fill(Some(Fill { paint: paint.into(), ..Default::default() }));
s.draw_path(&rect);
s.pop();
s.set_fill(None);
s.pop();
true
}
}
/// The ink coverage of a greyscale or bitmap image (255 = black, 0 = white or transparent) as a
/// DeviceGray image, painted into a luminosity mask.
#[derive(Clone, Hash)]
struct CoverageImage {
grey: Arc<Vec<u8>>,
size: (u32, u32),
}
impl CoverageImage {
fn new(rgba: &image::RgbaImage) -> Option<Self> {
let size = rgba.dimensions();
if size.0 == 0 || size.1 == 0 {
return None;
}
let grey = rgba.pixels().map(|p| ((u16::from(255 - p[0]) * u16::from(p[3]) + 127) / 255) as u8).collect();
Some(Self { grey: Arc::new(grey), size })
}
}
impl krilla::image::CustomImage for CoverageImage {
fn color_channel(&self) -> &[u8] {
&self.grey
}
fn alpha_channel(&self) -> Option<&[u8]> {
None
}
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]> {
None
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Luma
}
}
/// `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)
}
/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets
/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the
/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as
/// it is, and the version check still names the file.
fn relabel_catalog_version(mut data: Vec<u8>) -> Vec<u8> {
const KEY: &[u8] = b"/Version";
let mut from = 0;
while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) {
let mut i = from + at + KEY.len();
while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) {
i += 1;
}
let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.');
if data.get(i..i + 4) == Some(b"/1.7".as_slice())
&& ends
&& let Some(b) = data.get_mut(i + 3)
{
*b = b'6';
}
from = i;
}
data
}
/// 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())
}
/// Whether an ICC profile describes grey.
fn is_grey_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"GRAY".as_slice())
}
/// Whether `data` decodes to grey (with or without alpha), read from its header.
fn is_grey(data: &[u8]) -> bool {
use image::ImageDecoder;
image::ImageReader::new(std::io::Cursor::new(data))
.with_guessed_format()
.ok()
.and_then(|r| r.into_decoder().ok())
.is_some_and(|dec| !dec.color_type().has_color())
}
/// 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> {
// 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 i = mode.interpolate;
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(|| {
// A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
if let Some(grey) = GreyImage::new(data) {
return Image::from_custom(grey, i).ok();
}
// 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 grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
/// a page that only holds black prints as black, not as RGB.
#[derive(Clone, Hash)]
struct GreyImage {
grey: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
icc: Option<Arc<Vec<u8>>>,
}
impl GreyImage {
/// `None` when `data` isn't grey or can't be decoded.
fn new(data: &[u8]) -> Option<Self> {
if !is_grey(data) {
return None;
}
let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
let size = img.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
if pixels == 0 {
return None;
}
let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
for p in img.pixels() {
grey.push(p.0[0]);
alpha.push(p.0[1]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
Some(Self { grey: Arc::new(grey), alpha, size, icc })
}
}
impl krilla::image::CustomImage for GreyImage {
fn color_channel(&self) -> &[u8] {
&self.grey
}
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]> {
self.icc.as_deref().map(Vec::as_slice)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Luma
}
}
/// 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 mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
// A grey image stays a grey JPEG (#1426).
let grey = !img.color().has_color();
if grey {
let l = img.to_luma8();
image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
} else {
let rgb = img.to_rgb8();
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
}
jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { 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(), data: Arc::new(bytes), ..Default::default() }));
id
}
fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> {
Exporter {
drawing_items: Vec::new(),
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,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: 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);
// RGB-only exports get the RGB the screen shows.
let screen_rgb: Vec<u8> =
designcraft_images::decode_rgba(&bytes).map_or_else(Vec::new, |img| img.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect());
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;
// Unassociated alpha (2) decodes as inks, and as RGB for RGB-only exports.
// Associated alpha is rejected by both paths: retain both failure names.
let succeeds = alpha.is_none() || alpha == Some(2);
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 { screen_rgb.clone() } 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");
}
}
#[cfg(test)]
mod eps_tests {
use super::*;
use hayro_syntax::object::Name;
#[test]
fn eps_tiff_preview_keeps_cmyk_inks_in_pdf() {
let pixels = vec![0u8, 0, 0, 255, 0, 255, 255, 0];
let mut tiff = Vec::new();
tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tiff))
.unwrap()
.write_image::<tiff::encoder::colortype::CMYK8>(2, 1, &pixels)
.unwrap();
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 2 1\n%%EOF\n";
let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6];
for value in [30usize, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] {
eps.extend((value as u32).to_le_bytes());
}
eps.extend([0xFF, 0xFF]);
eps.extend_from_slice(ps);
eps.extend_from_slice(&tiff);
assert_eq!(designcraft_images::eps_proxy(&eps).unwrap().0, tiff);
let mut doc = Document::new(&designcraft_doc::build::NewDocument::default());
let aid = AssetId(doc.alloc());
doc.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
id: aid,
name: "cmyk.eps".into(),
mime: "application/postscript".into(),
link: None,
data: Arc::new(eps),
pixels: Some((2, 1)),
page: 0,
..Default::default()
}),
);
let mut item = Item::new(
designcraft_doc::ItemId(doc.alloc()),
doc.default_layer(),
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)),
);
item.content = Content::Graphic(designcraft_doc::Graphic {
asset: aid,
size: (2.0, 1.0),
xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
doc.insert_item(designcraft_doc::SpreadRef::Doc(0), item, None).unwrap();
for standard in [Standard::None, Standard::PdfX4] {
let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let pdf = hayro_syntax::Pdf::new(report.bytes).unwrap();
let images: Vec<_> = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| (s.dict().get::<Name>("ColorSpace").unwrap().as_str().to_string(), s.decoded().unwrap().into_owned()))
.collect();
assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}");
}
}
#[test]
fn civil_dates_match_the_old_cycle_conversion_including_beyond_chrono() {
use super::civil;
assert_eq!(civil(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil(1_791_504_000 + 3_723), (2026, 10, 9, 1, 2, 3));
// Beyond chrono's year range the old `civil` still converted; pin that output, never the
// epoch. `date_time` is what clamps the year to the PDF `0..=9999` range afterwards.
assert_eq!(civil(253_402_300_800), (10000, 1, 1, 0, 0, 0));
assert_eq!(civil(10_000_000_000_000), (318857, 5, 20, 17, 46, 40));
assert_eq!(civil(-10_000_000_000_000), (-314918, 8, 13, 6, 13, 20));
assert_eq!(civil(i64::MAX), (292277026596, 12, 4, 15, 30, 7));
assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
}
}