//! 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> { 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 { sheets(doc, opts).map(|v| v.iter().map(|s| s.spread).collect()).unwrap_or_default() } fn sheets(doc: &Document, opts: &PdfOptions) -> Result> { let count = doc.page_count(); let pages: Vec = 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 = Vec::new(); let mut seen_spreads: Vec = 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 = (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 { write::guard(|| export_pdf_with_report_inner(doc, cache, opts)) } fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result { 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, Vec<(f32, f32)>)], title: Option<&str>) -> Result> { write::guard(|| merge_pdfs_inner(parts, title)) } fn merge_pdfs_inner(parts: &[(Vec, Vec<(f32, f32)>)], title: Option<&str>) -> Result> { 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, } /// The printer spreads for `n` pages: (left, right) absolute page indices, `None` = blank. pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option, Option)> { 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 { write::guard(|| export_booklet_inner(doc, cache, opts)) } fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result { 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 { 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 { 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>, ConvertedImage>; // Unit tests force collisions through the real cache path; production keeps RandomState. #[cfg(test)] type ImageContentCache = HashMap>, ConvertedImage, std::hash::BuildHasherDefault>; #[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, images: HashMap>, // 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>, /// 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, /// Parsed placed SVGs. svgs: HashMap>>, pub fonts: HashMap>, pub reverse_cmaps: HashMap>>, /// 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, story_tags: HashMap>, /// 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, /// Marked content of each (story, paragraph index). pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>, } 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 { 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) { 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 { 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, 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 = 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::().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) { 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) { 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 { 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 = 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 = 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 { 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 { 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> { 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> { 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>, mode: RasterMode) -> Option { 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>, fmt: Option, mode: RasterMode) -> Option { 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>, /// Only when some pixel is not opaque. alpha: Option>>, size: (u32, u32), } impl SrgbImage { fn new(rgba: image::RgbaImage) -> Option { 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); 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 { 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 { 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 { 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, 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) { 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::("Subtype").is_some_and(|n| n.as_str() == "Image")) .unwrap(); (stream.dict().get::("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 = 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"); } }