mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-06 02:22:00 +00:00
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.
186 lines
7.8 KiB
Rust
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)));
|
|
}
|
|
}
|