423 lines
18 KiB
Rust
423 lines
18 KiB
Rust
//! Save PDF › Output: colours converted to a destination profile, the ICC profiles that tag them,
|
||
//! and the output intent and Trapped entries.
|
||
//!
|
||
//! - Conversion ([`ColorConversion`]): `destination` converts every colour to the destination
|
||
//! profile's model (CMYK or RGB) with the colour settings' intent, also colours already in that
|
||
//! model when their profile differs from the destination; `preserveNumbers` converts only the
|
||
//! colours of the other model (and Lab ones), keeping the numbers of those already in it.
|
||
//! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel
|
||
//! by pixel; printer's marks are not. The destination is a profile name; empty, the document's
|
||
//! profile for its colour mode.
|
||
//! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a
|
||
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
|
||
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
|
||
//! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey
|
||
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A always tags them.
|
||
//! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent
|
||
//! of the catalog with the condition, identifier and registry; Trapped goes in the document
|
||
//! information (`/False` when an output intent is written untrapped). PDF/A files carry their
|
||
//! own output intent: neither is written there.
|
||
|
||
use std::sync::{Arc, OnceLock};
|
||
|
||
use vectorcraft_color::Color;
|
||
use vectorcraft_color::cms::{self, Cms, Intent, Model, ProfileKind, ProofLut};
|
||
use vectorcraft_doc::{ColorMode, Document};
|
||
|
||
use crate::patch::{Patch, Xref};
|
||
use crate::{ColorConversion, PdfError, PdfSettings, ProfileInclusion, Standard};
|
||
|
||
/// Most characters of an output intent's text entries written.
|
||
const MAX_TEXT: usize = 255;
|
||
|
||
/// The destination colours are converted to.
|
||
struct Dest {
|
||
cms: Cms,
|
||
model: Model,
|
||
/// Colours already in the destination's model keep their numbers (Preserve Numbers, or the
|
||
/// destination is their own profile).
|
||
keep: bool,
|
||
/// The destination is sRGB (what images are).
|
||
srgb: bool,
|
||
}
|
||
|
||
/// How an export writes colours (see the module documentation).
|
||
pub(crate) struct ColorOut {
|
||
/// The colour settings colours come from.
|
||
source: Arc<Cms>,
|
||
dest: Option<Dest>,
|
||
/// Colours are written in ICC-based colour spaces.
|
||
pub tagged: bool,
|
||
/// The CMYK profile that tags CMYK colours.
|
||
cmyk_profile: String,
|
||
/// Tagged RGB colours are written as their sRGB equivalents: why (a warning), when their RGB
|
||
/// space isn't sRGB.
|
||
pub srgb_note: Option<String>,
|
||
/// The image transform, built when the first image needs it.
|
||
pixels: OnceLock<Option<Pixels>>,
|
||
intent: Intent,
|
||
/// The output intent and Trapped entries to write.
|
||
catalog: Option<Catalog>,
|
||
}
|
||
|
||
/// An output intent dictionary's entries.
|
||
struct OutputIntent {
|
||
/// `/OutputConditionIdentifier`.
|
||
id: String,
|
||
/// `/OutputCondition`, `/RegistryName` and `/Info` (left out when empty).
|
||
condition: String,
|
||
registry: String,
|
||
info: String,
|
||
/// The profile to embed (`/DestOutputProfile`): its name and number of components.
|
||
profile: Option<(String, u8)>,
|
||
}
|
||
|
||
/// The output intent and Trapped entries of the catalog and document information.
|
||
struct Catalog {
|
||
intent: Option<OutputIntent>,
|
||
/// `/Trapped`, when it is written.
|
||
trapped: Option<bool>,
|
||
}
|
||
|
||
impl Default for ColorOut {
|
||
/// Colours as they are, device colours, nothing in the catalog.
|
||
fn default() -> Self {
|
||
let source = cms::active();
|
||
let cmyk_profile = source.settings().cmyk.clone();
|
||
let intent = source.settings().intent;
|
||
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
|
||
}
|
||
}
|
||
|
||
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
|
||
/// destination is the profile of a document in colour mode `mode`.
|
||
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
|
||
let o = &set.output;
|
||
if o.conversion == ColorConversion::None {
|
||
return Ok(None);
|
||
}
|
||
let name = o.destination.trim();
|
||
let name = match (name.is_empty(), mode) {
|
||
(false, _) => name,
|
||
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
|
||
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
|
||
};
|
||
let unknown = || PdfError::BadSetting(format!("output.destination: no RGB or CMYK profile is called “{name}”"));
|
||
let info = cms::profile(name).ok_or_else(unknown)?;
|
||
let model = match info.kind {
|
||
ProfileKind::Rgb => Model::Rgb,
|
||
ProfileKind::Cmyk => Model::Cmyk,
|
||
ProfileKind::Gray => return Err(unknown()),
|
||
};
|
||
Ok(Some((info.name, model)))
|
||
}
|
||
|
||
/// Refuse a destination profile that isn't there (when converting).
|
||
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
|
||
// A blank destination is the document's own profile, which is always there.
|
||
if !set.output.destination.trim().is_empty() {
|
||
destination(set, ColorMode::Rgb, &cms::active())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// What `set`'s Output section leaves out, one warning each.
|
||
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
|
||
let o = &set.output;
|
||
let asks = !o.output_intent.trim().is_empty() || !o.output_condition_id.trim().is_empty();
|
||
let mut out = vec![];
|
||
if set.standard == Standard::PdfA2b {
|
||
if asks || o.trapped {
|
||
out.push("PDF/A files carry their own output intent: the output intent and Trapped entries are left out".into());
|
||
}
|
||
return out;
|
||
}
|
||
let name = o.output_intent.trim();
|
||
if !name.is_empty() && cms::profile(name).is_none() {
|
||
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
|
||
}
|
||
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
|
||
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
|
||
}
|
||
out
|
||
}
|
||
|
||
impl ColorOut {
|
||
/// How `set` writes the colours of `doc` (the colour settings are the source).
|
||
pub(crate) fn new(doc: &Document, set: &PdfSettings) -> Result<Self, PdfError> {
|
||
let mut out = Self::default();
|
||
let source = out.source.clone();
|
||
let st = source.settings();
|
||
if let Some((name, model)) = destination(set, doc.color_mode, &source)? {
|
||
let mut settings = st.clone();
|
||
let own = match model {
|
||
Model::Cmyk => std::mem::replace(&mut settings.cmyk, name.clone()),
|
||
_ => std::mem::replace(&mut settings.rgb, name.clone()),
|
||
};
|
||
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
|
||
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
|
||
if model == Model::Cmyk {
|
||
out.cmyk_profile = name.clone();
|
||
}
|
||
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
|
||
}
|
||
out.tagged = set.standard == Standard::PdfA2b
|
||
|| match set.output.profiles {
|
||
ProfileInclusion::None => false,
|
||
ProfileInclusion::All | ProfileInclusion::Destination => true,
|
||
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
|
||
};
|
||
let rgb = match &out.dest {
|
||
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
|
||
_ => (source.rgb_is_srgb(), st.rgb.clone()),
|
||
};
|
||
if out.tagged && !rgb.0 {
|
||
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
|
||
}
|
||
out.catalog = catalog(set);
|
||
Ok(out)
|
||
}
|
||
|
||
/// Transparency blends in CMYK: converting to CMYK, or (without conversion) in a CMYK
|
||
/// document (`cmyk`).
|
||
pub(crate) fn blends_cmyk(&self, cmyk: bool) -> bool {
|
||
self.dest.as_ref().map_or(cmyk, |d| d.model == Model::Cmyk)
|
||
}
|
||
|
||
/// The ICC profile that tags CMYK colours (when they are tagged).
|
||
pub(crate) fn cmyk_icc(&self) -> Option<krilla::icc::ICCProfile<4>> {
|
||
krilla::icc::ICCProfile::new(&cms::icc_bytes(&self.cmyk_profile).ok()?)
|
||
}
|
||
|
||
/// The name of that profile.
|
||
pub(crate) fn cmyk_profile(&self) -> &str {
|
||
&self.cmyk_profile
|
||
}
|
||
|
||
/// Colour `c` as it is written with `intent`: converted to the destination (with `convert`;
|
||
/// printer's marks aren't), else RGB and Lab colours of a CMYK document (`cmyk_doc`)
|
||
/// separated into CMYK; tagged RGB colours as their sRGB equivalents.
|
||
pub(crate) fn color(&self, c: &Color, intent: Intent, cmyk_doc: bool, convert: bool) -> Color {
|
||
let s = &self.source;
|
||
let c = match &self.dest {
|
||
Some(d) if convert => d.convert(s, c, intent),
|
||
_ if cmyk_doc && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) => {
|
||
let [c, m, y, k] = s.to_cmyk(c, intent);
|
||
Color::Cmyk { c, m, y, k }
|
||
}
|
||
_ => *c,
|
||
};
|
||
match c {
|
||
Color::Rgb { r, g, b } if self.srgb_note.is_some() => {
|
||
let space = self.dest.as_ref().filter(|d| convert && d.model == Model::Rgb).map_or(&**s, |d| &d.cms);
|
||
let [r, g, b] = space.rgb_to_srgb([r, g, b]);
|
||
Color::Rgb { r, g, b }
|
||
}
|
||
c => c,
|
||
}
|
||
}
|
||
|
||
/// Ink values of `c` in the CMYK space of the output (a spot colour's alternate).
|
||
pub(crate) fn cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
|
||
match &self.dest {
|
||
Some(d) if d.model == Model::Cmyk => d.cms.to_cmyk(&d.convert(&self.source, c, intent), intent),
|
||
_ => self.source.to_cmyk(c, intent),
|
||
}
|
||
}
|
||
|
||
/// How colour images' pixels are converted; `None` when they are written as they are.
|
||
pub(crate) fn pixels(&self) -> Option<&Pixels> {
|
||
self.pixels
|
||
.get_or_init(|| {
|
||
let d = self.dest.as_ref()?;
|
||
let intent = self.intent;
|
||
match d.model {
|
||
Model::Cmyk => {
|
||
let plane = |k: bool| {
|
||
ProofLut::build(|rgb| {
|
||
let v = d.cms.srgb_to_cmyk(rgb, intent);
|
||
if k { [v[3], 0.0, 0.0] } else { [v[0], v[1], v[2]] }
|
||
})
|
||
};
|
||
Some(Pixels::Cmyk(Box::new([plane(false), plane(true)])))
|
||
}
|
||
// Images are sRGB: converted unless the destination is sRGB, they keep their
|
||
// numbers or RGB is tagged as sRGB.
|
||
_ if d.srgb || d.keep || self.tagged => None,
|
||
_ => Some(Pixels::Rgb(Box::new(ProofLut::build(|rgb| d.cms.srgb_to_rgb(rgb))))),
|
||
}
|
||
})
|
||
.as_ref()
|
||
}
|
||
|
||
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
|
||
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
|
||
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
|
||
let mut patch = Patch::new(&xref);
|
||
let mut warnings = vec![];
|
||
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
|
||
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
|
||
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
|
||
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
|
||
warnings.push("the file has an output intent already: the one asked for is left out".into());
|
||
} else {
|
||
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
|
||
for (key, v) in [("OutputCondition", condition), ("RegistryName", registry), ("Info", info)] {
|
||
if !v.is_empty() {
|
||
dict.push_str(&format!("/{key}{}", text_string(v)));
|
||
}
|
||
}
|
||
if let Some((name, n)) = profile {
|
||
let icc = cms::icc_bytes(name).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
|
||
let data = deflate(&icc).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
|
||
let mut stream = format!("<</N {n}/Length {}/Filter/FlateDecode>>\nstream\n", data.len()).into_bytes();
|
||
stream.extend_from_slice(&data);
|
||
stream.extend_from_slice(b"\nendstream");
|
||
dict.push_str(&format!("/DestOutputProfile {} 0 R", patch.add_object(stream)));
|
||
}
|
||
dict.push_str(">>");
|
||
let oi = patch.add_object(dict.into_bytes());
|
||
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
|
||
}
|
||
}
|
||
if let Some(trapped) = cat.trapped {
|
||
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
|
||
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
|
||
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
|
||
patch.replace(info, info, entry.as_bytes().to_vec());
|
||
}
|
||
Some(_) => {}
|
||
None => {
|
||
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
|
||
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
|
||
}
|
||
}
|
||
}
|
||
Ok((patch.apply(&pdf, &xref)?, warnings))
|
||
}
|
||
}
|
||
|
||
impl Dest {
|
||
/// `c` converted from `source` (see the module documentation).
|
||
fn convert(&self, source: &Cms, c: &Color, intent: Intent) -> Color {
|
||
match (self.model, c) {
|
||
(_, Color::Gray { .. }) | (Model::Cmyk, Color::Cmyk { .. }) | (Model::Rgb, Color::Rgb { .. })
|
||
if self.keep || matches!(c, Color::Gray { .. }) =>
|
||
{
|
||
*c
|
||
}
|
||
(Model::Cmyk, _) => {
|
||
// Through Lab (CMYK of another profile, Lab) or the display colour (RGB).
|
||
let [c, m, y, k] = match c {
|
||
Color::Rgb { .. } => self.cms.srgb_to_cmyk(source.display_rgb(c), intent),
|
||
_ => self.cms.lab_to_cmyk(source.lab(c), intent),
|
||
};
|
||
Color::Cmyk { c, m, y, k }
|
||
}
|
||
_ => {
|
||
let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c));
|
||
Color::Rgb { r, g, b }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
|
||
fn catalog(set: &PdfSettings) -> Option<Catalog> {
|
||
let o = &set.output;
|
||
if set.standard == Standard::PdfA2b {
|
||
return None;
|
||
}
|
||
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
|
||
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
|
||
let profile = cms::profile(name).and_then(|p| match p.kind {
|
||
ProfileKind::Rgb => Some((p.name, 3)),
|
||
ProfileKind::Cmyk => Some((p.name, 4)),
|
||
ProfileKind::Gray => None,
|
||
});
|
||
let text = |s: &str| s.trim().chars().take(MAX_TEXT).collect::<String>();
|
||
OutputIntent {
|
||
id: text(if id.is_empty() { name } else { id }),
|
||
condition: text(&o.output_condition),
|
||
registry: text(&o.registry),
|
||
info: text(name),
|
||
profile,
|
||
}
|
||
});
|
||
let trapped = (o.trapped || intent.is_some()).then_some(o.trapped);
|
||
(intent.is_some() || trapped.is_some()).then_some(Catalog { intent, trapped })
|
||
}
|
||
|
||
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
|
||
/// byte order mark) in hex.
|
||
fn text_string(s: &str) -> String {
|
||
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
|
||
let mut out = String::with_capacity(s.len() + 2);
|
||
out.push('(');
|
||
for c in s.chars() {
|
||
if matches!(c, '(' | ')' | '\\') {
|
||
out.push('\\');
|
||
}
|
||
out.push(c);
|
||
}
|
||
out.push(')');
|
||
out
|
||
} else {
|
||
let mut out = String::from("<FEFF");
|
||
for u in s.encode_utf16() {
|
||
out.push_str(&format!("{u:04X}"));
|
||
}
|
||
out.push('>');
|
||
out
|
||
}
|
||
}
|
||
|
||
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
||
use std::io::Write;
|
||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
e.write_all(data)?;
|
||
e.finish()
|
||
}
|
||
|
||
/// How colour images' pixels are converted to the destination.
|
||
pub(crate) enum Pixels {
|
||
/// To another RGB space.
|
||
Rgb(Box<ProofLut>),
|
||
/// To CMYK: the cyan, magenta and yellow plane, and the black one.
|
||
Cmyk(Box<[ProofLut; 2]>),
|
||
}
|
||
|
||
impl Pixels {
|
||
pub(crate) fn is_cmyk(&self) -> bool {
|
||
matches!(self, Self::Cmyk(_))
|
||
}
|
||
|
||
/// Convert the RGB samples of straight RGBA pixels `rgba` in place (RGB destinations).
|
||
pub(crate) fn rgb_in_place(&self, rgba: &mut [u8]) {
|
||
if let Self::Rgb(lut) = self {
|
||
for p in rgba.as_chunks_mut::<4>().0 {
|
||
let [r, g, b] = lut.apply8([p[0], p[1], p[2]]);
|
||
(p[0], p[1], p[2]) = (r, g, b);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The CMYK samples of straight RGBA pixels `rgba` (CMYK destinations).
|
||
pub(crate) fn cmyk(&self, rgba: &[u8]) -> Option<Vec<u8>> {
|
||
let Self::Cmyk(luts) = self else { return None };
|
||
let [cmy, k] = &**luts;
|
||
Some(
|
||
rgba.as_chunks::<4>()
|
||
.0
|
||
.iter()
|
||
.flat_map(|p| {
|
||
let rgb = [p[0], p[1], p[2]];
|
||
let [c, m, y] = cmy.apply8(rgb);
|
||
[c, m, y, k.apply8(rgb)[0]]
|
||
})
|
||
.collect(),
|
||
)
|
||
}
|
||
}
|