Files
craft-libs/crates/pdf/src/write.rs
T
aiongg eedd0246df krilla: CMYK print color in the vendored krilla, and a craft-pdf print preset
Print PDFs written through krilla separated wrongly: transparency groups blended in RGB
(K 50% over C100 separated as C84 M34 Y32 K37 instead of C50 K50), and there was no
overprint, knockout, DeviceN, soft-mask backdrop or print output intent.

Patches in vendor/krilla/patches/, recorded in README.craft.md and SOURCE.json:
- 02 blending color space: GroupColorSpace { Rgb (default, unchanged output), Cmyk }
- 03 overprint: Surface::push_overprint (/op /OP /OPM)
- 04 knockout groups: Surface::push_group(GroupOptions { isolated, knockout })
- 05 soft-mask backdrop and transfer function (/BC /TR)
- 06 DeviceN and NChannel colors for fills and strokes
- 07 DeviceN images and shadings
- 08 print output intent (GTS_PDFX), /Trapped, PDF/X version markers

craft_pdf::write::print::settings(PrintColor) turns them on for print export.
2026-10-20 03:41:13 +00:00

186 lines
7.8 KiB
Rust

//! PDF authoring shared by Craft apps, using Krilla's drawing primitives.
//!
//! Applications supply already laid-out pages and retain their document models, font
//! licensing and fallback policy, export presets, tagging semantics and postprocessing.
//! [`backend`] exposes Krilla explicitly; this is not an independent graphics model.
use std::panic::{AssertUnwindSafe, catch_unwind};
pub use krilla as backend;
// Require the root workspace patch; an unpatched backend can abort during unwind, and
// writes RGB blending groups and no print output intent.
const _: bool = backend::CRAFT_UNWIND_POISONING;
const _: bool = backend::CRAFT_PRINT_COLOR;
pub mod print;
use backend::configure::{Archival, Configuration, ConfigurationBuilder, ConfigurationError, PdfVersion};
use backend::text::{Font, Tag};
/// Largest individual font copied for embedding (512 MiB).
pub const MAX_FONT_BYTES: usize = 512 * 1024 * 1024;
/// Maximum number of variation coordinates accepted for one font instance.
pub const MAX_FONT_VARIATIONS: usize = 256;
/// Shared failures retain backend diagnostics instead of silently emitting a partial PDF.
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
#[error("{0:?}")]
Configuration(ConfigurationError),
#[error("{0:?}")]
Serialization(#[source] Box<backend::error::KrillaError>),
#[error("PDF export failed internally: {0}")]
BackendPanic(String),
#[error("font exceeds the {MAX_FONT_BYTES}-byte embedding limit")]
FontLimit,
#[error("font variation coordinates must be finite and number at most {MAX_FONT_VARIATIONS}")]
FontVariations,
#[error("font data could not be allocated")]
Allocation,
#[error("font could not be read for embedding")]
InvalidFont,
#[error("the print profile is not a readable CMYK ICC profile")]
InvalidProfile,
}
/// Run an entire app export, including drawing and postprocessing, behind a native
/// unwind guard. Existing application errors pass through unchanged.
///
/// Discard any writer or drawing state captured by the closure after a panic. Prefer
/// constructing the writer inside the closure. WASM aborts and process-wide allocation
/// failures cannot be caught; this is not a memory or execution-time budget.
pub fn guard<T, E: From<WriteError>>(operation: impl FnOnce() -> Result<T, E>) -> Result<T, E> {
catch_unwind(AssertUnwindSafe(operation)).map_err(|payload| {
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied())
.unwrap_or("non-string panic payload");
E::from(WriteError::BackendPanic(message.chars().take(1024).collect()))
})?
}
/// Build a version/archival configuration without changing application export policy.
pub fn configuration(version: PdfVersion, archival: Option<Archival>) -> Result<Configuration, WriteError> {
guard(|| {
let mut builder = ConfigurationBuilder::new().with_version(version);
if let Some(archival) = archival {
builder = builder.with_archival_validator(archival);
}
builder.finish().map_err(WriteError::Configuration)
})
}
/// Common document information. Callers decide which values to provide and whether to
/// trim them. Dates are supplied explicitly; this crate never consults a platform clock.
#[derive(Clone, Debug, Default)]
pub struct Metadata {
pub creator: String,
pub producer: String,
pub title: Option<String>,
pub authors: Vec<String>,
pub description: Option<String>,
pub keywords: Vec<String>,
pub created: Option<backend::metadata::DateTime>,
}
impl Metadata {
fn into_backend(self) -> backend::metadata::Metadata {
let mut metadata =
backend::metadata::Metadata::new().creator(self.creator).producer(self.producer).authors(self.authors).keywords(self.keywords);
if let Some(title) = self.title {
metadata = metadata.title(title);
}
if let Some(description) = self.description {
metadata = metadata.description(description);
}
if let Some(created) = self.created {
metadata = metadata.creation_date(created);
}
metadata
}
}
/// A writer whose serialization is fallible and consumes the document on every outcome.
pub struct Writer {
document: backend::Document,
}
impl Writer {
/// Keep all backend settings, including colour profiles, compression and validators.
pub fn new(settings: backend::SerializeSettings, metadata: Metadata) -> Self {
Self::new_with_language(settings, metadata, None)
}
/// Set an optional document language without changing the common metadata fields.
/// Empty language is treated as absent. Callers validate nonempty tags, which pass through unchanged.
pub fn new_with_language(settings: backend::SerializeSettings, metadata: Metadata, language: Option<String>) -> Self {
let mut metadata = metadata.into_backend();
if let Some(language) = language.filter(|language| !language.is_empty()) {
metadata = metadata.language(language);
}
let mut document = backend::Document::new_with(settings);
document.set_metadata(metadata);
Self { document }
}
/// Advanced drawing, resource, outline and tag-tree access for an application's adapter.
/// Use [`guard`] around the whole export to cover drawing as well as serialization.
pub fn document_mut(&mut self) -> &mut backend::Document {
&mut self.document
}
/// Serialize, preserving validation failures and catching native backend unwinds.
/// The returned byte vector is not a bound on peak serialization memory.
pub fn finish(self) -> Result<Vec<u8>, WriteError> {
guard(|| self.document.finish().map_err(|error| WriteError::Serialization(Box::new(error))))
}
}
fn check_font_input(size: usize, variations: Option<&[([u8; 4], f32)]>) -> Result<(), WriteError> {
if size > MAX_FONT_BYTES {
return Err(WriteError::FontLimit);
}
if variations.is_some_and(|coords| coords.len() > MAX_FONT_VARIATIONS || coords.iter().any(|(_, value)| !value.is_finite())) {
return Err(WriteError::FontVariations);
}
Ok(())
}
/// Copy and prepare an already selected, licensed font for Krilla's subset embedding.
/// `None` retains static-font construction; `Some` retains variable-font construction,
/// including an empty coordinate list. Selection, caching, shaping and fallback belong
/// to the app. An error never substitutes another font or drops text silently.
pub fn font(data: &[u8], index: u32, variations: Option<&[([u8; 4], f32)]>) -> Result<Font, WriteError> {
check_font_input(data.len(), variations)?;
let mut owned = Vec::new();
owned.try_reserve_exact(data.len()).map_err(|_| WriteError::Allocation)?;
owned.extend_from_slice(data);
guard(|| {
let font = match variations {
None => Font::new(owned.into(), index),
Some(coords) => {
let coords: Vec<_> = coords.iter().map(|(tag, value)| (Tag::new(tag), *value)).collect();
Font::new_variable(owned.into(), index, &coords)
}
};
font.ok_or(WriteError::InvalidFont)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn font_bounds_are_checked_without_allocating_the_input() {
assert!(check_font_input(MAX_FONT_BYTES, None).is_ok());
assert!(matches!(check_font_input(MAX_FONT_BYTES + 1, None), Err(WriteError::FontLimit)));
assert!(matches!(check_font_input(usize::MAX, None), Err(WriteError::FontLimit)));
let coords = vec![(*b"wght", 400.0); MAX_FONT_VARIATIONS + 1];
assert!(matches!(check_font_input(0, Some(&coords)), Err(WriteError::FontVariations)));
}
}