mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
269 lines
10 KiB
Rust
269 lines
10 KiB
Rust
//! DesignCraft PDF export (print), written with `krilla`.
|
||
//!
|
||
//! [`export_pdf`] writes one PDF page per document page (or per spread) and mirrors the renderer's
|
||
//! walk (`designcraft-render`): layers back to front, parent items first (with page-number markers
|
||
//! resolved for each page), then the spread's own items. What goes into the file:
|
||
//!
|
||
//! - **Boxes:** MediaBox = trim + bleed (+ room for printer's marks), TrimBox = the page, BleedBox =
|
||
//! page + document bleed. Content is clipped to the bleed box.
|
||
//! - **Vectors:** fills (solid and gradient swatches), strokes with alignment, dashes, caps, joins
|
||
//! and miter limits, corner options, opacity and blend modes (transparency groups), the simple
|
||
//! drop shadow.
|
||
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
|
||
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
|
||
//! as `/Separation /All`.
|
||
//! Gradient stops on swatches shade in their inks: one spot with tints of itself or `[Paper]`
|
||
//! as a `/Separation` shading, a spot with process inks or a second spot as `/DeviceN`
|
||
//! (process colorants first; not in PDF/A, which goes to process color with a warning), and in
|
||
//! print a process gradient to `[Paper]` as DeviceCMYK. Plain-color stops keep their own
|
||
//! space; gradients mixing RGB with CMYK or Gray are converted to RGB with an export warning.
|
||
//! - **Overprint:** Overprint Fill, Stroke and Gap, overprinting text and 100% [Black] (when
|
||
//! Appearance of Black says so) as `/op` and `/OP` with `/OPM 1`; left out of PDF/A.
|
||
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
|
||
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A
|
||
//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own
|
||
//! carries sRGB (`assets/icc/sRGB-v2-magic.icc`).
|
||
//! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a
|
||
//! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the
|
||
//! right characters), so text is selectable and searchable.
|
||
//! - **Marks:** crop marks, bleed marks and a page-information line, drawn in `[Registration]`.
|
||
//! - **Standards:** PDF/A-2b through krilla's validator and PDF/X-4 output intent/identification
|
||
//! with built-in conformance checks. PDF/X-1a:2003 is the PDF/X-4 print file flattened by
|
||
//! craft-prepress (no transparency, only DeviceCMYK, DeviceGray, Separation and DeviceN) and
|
||
//! written by krilla as PDF 1.4 with the PDF/X-1a identification. `designcraft-cli validate-pdf` exposes those checks for CI.
|
||
//!
|
||
//! Not yet: tagged PDF, bookmarks, hyperlinks, layers as optional content.
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod export;
|
||
mod gradient_inks;
|
||
pub mod inks;
|
||
mod links;
|
||
mod marks;
|
||
mod text;
|
||
|
||
mod forms;
|
||
mod optional_content;
|
||
mod pdfx;
|
||
mod pdfx1a;
|
||
mod transitions;
|
||
pub use export::{
|
||
BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, print_color, sheet_spreads,
|
||
};
|
||
pub use pdfx::{check_pdfx4, has_rgb_groups, make_pdfx4};
|
||
pub use pdfx1a::check_pdfx1a;
|
||
pub use transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions};
|
||
|
||
/// PDF standard to target.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum Standard {
|
||
/// Plain PDF 1.7.
|
||
#[default]
|
||
None,
|
||
/// PDF/X-4 (PDF 1.6, trim/bleed boxes, output intent and identification).
|
||
PdfX4,
|
||
/// PDF/X-1a:2003 (PDF 1.4, CMYK and spot only, no transparency): the PDF/X-4 file flattened.
|
||
PdfX1a,
|
||
/// PDF/A-2b (archival; validated by krilla).
|
||
PdfA2b,
|
||
}
|
||
|
||
impl Standard {
|
||
/// `none`, `x4` / `pdfx4` / `PDF/X-4`, `x1a` / `pdfx1a` / `PDF/X-1a:2003`, `a2b` / `pdfa2b` /
|
||
/// `PDF/A-2b`.
|
||
pub fn parse(s: &str) -> Option<Self> {
|
||
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
|
||
match k.as_str() {
|
||
"" | "none" => Some(Self::None),
|
||
"x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4),
|
||
"x1a" | "pdfx1a" | "pdfx1a2003" => Some(Self::PdfX1a),
|
||
"a2b" | "pdfa2b" => Some(Self::PdfA2b),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// PDF/X: a CMYK output intent, trim boxes, no interpolation.
|
||
pub fn is_pdfx(self) -> bool {
|
||
matches!(self, Self::PdfX4 | Self::PdfX1a)
|
||
}
|
||
}
|
||
|
||
/// Printer's marks (Export PDF › Marks and Bleeds).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Marks {
|
||
pub crop: bool,
|
||
pub bleed: bool,
|
||
/// Document title, page name, date — in the slug area under the page.
|
||
pub page_info: bool,
|
||
/// Line weight in points (0.25 default).
|
||
pub weight: f64,
|
||
/// Distance of the marks from the trim edge in points (6 default). Marks never start inside
|
||
/// the bleed.
|
||
pub offset: f64,
|
||
}
|
||
|
||
impl Marks {
|
||
pub const NONE: Marks = Marks { crop: false, bleed: false, page_info: false, weight: 0.25, offset: 6.0 };
|
||
pub const ALL: Marks = Marks { crop: true, bleed: true, page_info: true, weight: 0.25, offset: 6.0 };
|
||
pub fn any(&self) -> bool {
|
||
self.crop || self.bleed || self.page_info
|
||
}
|
||
}
|
||
|
||
impl Default for Marks {
|
||
fn default() -> Self {
|
||
Self::NONE
|
||
}
|
||
}
|
||
|
||
/// Export options.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PdfOptions {
|
||
/// 0-based absolute page indices to export, in order; `None` = all pages.
|
||
pub pages: Option<Vec<usize>>,
|
||
/// One PDF page per spread (spreads containing any chosen page) instead of per page.
|
||
pub spreads: bool,
|
||
/// Include the document bleed (`DocSettings::bleed`) around each page.
|
||
pub bleed: bool,
|
||
pub marks: Marks,
|
||
pub standard: Standard,
|
||
/// Re-encode non-JPEG raster images as JPEG (quality 90) when they have no transparency.
|
||
/// Off = lossless (JPEG data is always passed through unchanged).
|
||
pub compress_images: bool,
|
||
/// Flate-compress content streams.
|
||
pub compress: bool,
|
||
/// Title for the metadata; `None` = the document title.
|
||
pub title: Option<String>,
|
||
pub author: Option<String>,
|
||
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm).
|
||
pub created: Option<i64>,
|
||
/// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt
|
||
/// text, parent-page items and printer's marks artifacts.
|
||
pub tagged: bool,
|
||
/// Interactive PDF: placed video and sound are embedded and play in Screen annotations.
|
||
pub media: bool,
|
||
/// PDF/X-1a: the resolution (pixels per inch) craft-prepress's flattener rasterizes complex
|
||
/// transparent regions at.
|
||
pub flatten_dpi: f32,
|
||
/// Soft drop shadows drawn as their shadow color through a coverage mask, by item id (spread
|
||
/// items only). Items without one draw the simple offset silhouette.
|
||
pub shadows: std::sync::Arc<std::collections::HashMap<designcraft_doc::ItemId, ShadowMask>>,
|
||
}
|
||
|
||
/// A soft drop shadow's coverage over `rect` (spread coordinates): `width` × `height` greys,
|
||
/// 255 = the shadow color at full strength. Its opacity is in the coverage.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct ShadowMask {
|
||
pub rect: designcraft_geom::Rect,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub coverage: std::sync::Arc<Vec<u8>>,
|
||
}
|
||
|
||
impl Default for PdfOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
pages: None,
|
||
spreads: false,
|
||
bleed: false,
|
||
marks: Marks::NONE,
|
||
standard: Standard::None,
|
||
compress_images: false,
|
||
compress: true,
|
||
title: None,
|
||
author: None,
|
||
created: None,
|
||
tagged: false,
|
||
media: false,
|
||
flatten_dpi: 300.0,
|
||
shadows: Default::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bytes plus non-fatal warnings (features approximated or dropped).
|
||
#[derive(Clone, Debug)]
|
||
pub struct ExportReport {
|
||
pub bytes: Vec<u8>,
|
||
pub pages: usize,
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||
pub enum PdfError {
|
||
#[error("nothing to export (no pages selected)")]
|
||
NoPages,
|
||
#[error("page {0} does not exist")]
|
||
BadPage(usize),
|
||
#[error("bad page range `{0}`")]
|
||
BadRange(String),
|
||
#[error("PDF writer error: {0}")]
|
||
Write(String),
|
||
}
|
||
|
||
pub type Result<T> = std::result::Result<T, PdfError>;
|
||
|
||
/// Parse a 1-based page range like `"1-3, 5, 8-"` into 0-based indices (`count` pages in the
|
||
/// document). `""` / `"all"` = every page.
|
||
pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> {
|
||
let t = s.trim();
|
||
if t.is_empty() || t.eq_ignore_ascii_case("all") {
|
||
return Ok((0..count).collect());
|
||
}
|
||
let bad = || PdfError::BadRange(s.to_string());
|
||
let num = |v: &str| v.trim().parse::<usize>().map_err(|_| bad());
|
||
let mut out = Vec::new();
|
||
for part in t.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||
let (a, b) = match part.split_once('-') {
|
||
Some((a, b)) => {
|
||
let a = if a.trim().is_empty() { 1 } else { num(a)? };
|
||
let b = if b.trim().is_empty() { count } else { num(b)? };
|
||
(a, b)
|
||
}
|
||
None => {
|
||
let v = num(part)?;
|
||
(v, v)
|
||
}
|
||
};
|
||
if a == 0 || b == 0 || a > b {
|
||
return Err(bad());
|
||
}
|
||
for p in a..=b {
|
||
if p > count {
|
||
return Err(PdfError::BadPage(p));
|
||
}
|
||
if !out.contains(&(p - 1)) {
|
||
out.push(p - 1);
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
impl From<craft_pdf::write::WriteError> for PdfError {
|
||
fn from(error: craft_pdf::write::WriteError) -> Self {
|
||
Self::Write(error.to_string())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod print_tests;
|
||
#[cfg(test)]
|
||
mod rtl_tests;
|
||
#[cfg(test)]
|
||
mod tests;
|
||
#[cfg(test)]
|
||
mod x1a_tests;
|
||
|
||
// Run the same public API/output contracts with the unit-only colliding cache hasher as well
|
||
// as the production RandomState used by the integration-test executable.
|
||
#[cfg(test)]
|
||
extern crate self as designcraft_pdf;
|
||
#[cfg(test)]
|
||
#[path = "../tests/support/image_content_cache_contract.rs"]
|
||
mod image_content_cache_collision_exports;
|
||
#[cfg(test)]
|
||
#[path = "../tests/support/image_fixtures.rs"]
|
||
mod image_fixtures;
|