mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 08:51:25 +00:00
Save PDF > Output now applies: colours convert to a destination profile (destination, or preserveNumbers keeping the numbers of colours already in its model; grey stays grey; images and freeform gradient images convert too; printer's marks don't). Profile inclusion writes ICC-based colour spaces (CMYK with the destination's or the document's profile, RGB as sRGB, grey), and transparency in CMYK output blends in the CMYK profile's space. The output intent profile is embedded as the catalog's /GTS_PDFX output intent with its condition, identifier and registry, and Trapped goes in the document information (PDF/A keeps its own). A shared patch module edits written PDFs (Lab spot alternates use it too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
16 KiB
Rust
315 lines
16 KiB
Rust
//! Save PDF › Output: colours converted to the destination (or their numbers preserved), ICC
|
||
//! profiles tagging them, images converted too, and the output intent and Trapped entries.
|
||
|
||
use std::io::Cursor;
|
||
use std::sync::Arc;
|
||
|
||
use hayro_syntax::Pdf;
|
||
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream};
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, SRGB};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
|
||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||
|
||
use crate::*;
|
||
|
||
/// A document of `mode` with a rectangle filled with each of `fills`, side by side.
|
||
fn doc(mode: ColorMode, fills: &[Color]) -> Document {
|
||
let mut d = Document::new(200.0, 100.0);
|
||
d.color_mode = mode;
|
||
let l = d.layers[0].id;
|
||
for (i, c) in fills.iter().enumerate() {
|
||
let x = 10.0 + 30.0 * i as f64;
|
||
let n =
|
||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
|
||
d.insert(Some(l), i, n).unwrap();
|
||
}
|
||
d
|
||
}
|
||
|
||
/// The export of `d` with `settings` (content streams left readable).
|
||
fn pdf(d: &Document, settings: Value) -> ExportReport {
|
||
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
|
||
settings.compression.compress_text = false;
|
||
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
|
||
}
|
||
|
||
/// The fill colours the first page's content sets: (operator, operands).
|
||
fn colours(pdf: &[u8]) -> Vec<(String, Vec<f32>)> {
|
||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||
let pages = file.pages();
|
||
let content = String::from_utf8_lossy(pages.iter().next().unwrap().page_stream().unwrap()).into_owned();
|
||
let (mut nums, mut out) = (vec![], vec![]);
|
||
for t in content.split_whitespace() {
|
||
if let Ok(v) = t.parse::<f32>() {
|
||
nums.push(v);
|
||
continue;
|
||
}
|
||
if ["rg", "k", "g", "scn"].contains(&t) {
|
||
out.push((t.to_string(), nums.clone()));
|
||
}
|
||
nums.clear();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// `v` as the writer stores it (8 bits a component).
|
||
fn q8(v: &[f32]) -> Vec<f32> {
|
||
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
|
||
}
|
||
|
||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||
}
|
||
|
||
/// The colour management of a CMYK destination, as the export builds it.
|
||
fn to_cmyk(dest: &str) -> Cms {
|
||
Cms::new(&ColorSettings { cmyk: dest.into(), ..cms::active_settings() }).unwrap()
|
||
}
|
||
|
||
/// The ICC profiles the ICC-based colour spaces of `pdf` use: (components, profile bytes).
|
||
fn icc_profiles(pdf: &[u8]) -> Vec<(i32, Vec<u8>)> {
|
||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||
let text = String::from_utf8_lossy(pdf);
|
||
let mut out = vec![];
|
||
for (at, _) in text.match_indices("[/ICCBased ") {
|
||
let n: i32 = text[at + 11..].split(' ').next().unwrap().parse().unwrap();
|
||
let s = file.xref().get::<Stream<'_>>(ObjectIdentifier::new(n, 0)).unwrap();
|
||
let entry = (s.dict().get::<i32>(b"N").unwrap(), s.decoded().unwrap().into_owned());
|
||
if !out.contains(&entry) {
|
||
out.push(entry);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn converting_to_a_cmyk_destination_writes_k_instead_of_rg() {
|
||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||
let d = doc(ColorMode::Rgb, &[red]);
|
||
assert_eq!(colours(&pdf(&d, json!({})).bytes), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "without conversion");
|
||
let ink = q8(&to_cmyk(GENERIC_CMYK).srgb_to_cmyk([1.0, 0.0, 0.0], cms::active_settings().intent));
|
||
for conversion in ["destination", "preserveNumbers"] {
|
||
let r = pdf(&d, json!({"output": {"conversion": conversion, "destination": GENERIC_CMYK}}));
|
||
let c = colours(&r.bytes);
|
||
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{conversion}: {c:?} vs {ink:?}");
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
}
|
||
// A blank destination is the document's profile: the RGB document's colours stay RGB.
|
||
assert_eq!(colours(&pdf(&d, json!({"output": {"conversion": "destination"}})).bytes)[0].0, "rg");
|
||
}
|
||
|
||
#[test]
|
||
fn preserve_numbers_keeps_cmyk_values_that_destination_converts() {
|
||
let ink = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||
let d = doc(ColorMode::Rgb, &[ink, Color::Gray { k: 0.25 }]);
|
||
let numbers = q8(&[0.1, 0.2, 0.3, 0.4]);
|
||
let fills = |conversion: &str, dest: &str| colours(&pdf(&d, json!({"output": {"conversion": conversion, "destination": dest}})).bytes);
|
||
let kept = fills("preserveNumbers", DEVICE_CMYK);
|
||
assert!(kept[0].0 == "k" && close(&kept[0].1, &numbers), "{kept:?}");
|
||
// Grey stays grey.
|
||
assert!(kept[1].0 == "g" && close(&kept[1].1, &q8(&[0.75])), "{kept:?}");
|
||
// Convert to destination: CMYK of another profile goes through Lab into the destination.
|
||
let converted = fills("destination", DEVICE_CMYK);
|
||
let expected = q8(&to_cmyk(DEVICE_CMYK).lab_to_cmyk(cms::active().lab(&ink), cms::active_settings().intent));
|
||
assert!(converted[0].0 == "k" && close(&converted[0].1, &expected), "{converted:?} vs {expected:?}");
|
||
assert!(converted[0].1.iter().zip(&numbers).any(|(a, b)| (a - b).abs() > 0.02), "the numbers change: {converted:?}");
|
||
// …unless the destination is their own profile.
|
||
let own = fills("destination", &cms::active_settings().cmyk);
|
||
assert!(close(&own[0].1, &numbers), "{own:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn converting_to_rgb_turns_cmyk_into_rg() {
|
||
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||
let d = doc(ColorMode::Cmyk, &[ink]);
|
||
assert_eq!(colours(&pdf(&d, json!({})).bytes)[0].0, "k");
|
||
let c = colours(&pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": SRGB}})).bytes);
|
||
assert!(c[0].0 == "rg" && close(&c[0].1, &q8(&cms::active().display_rgb(&ink))), "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_destination_is_refused() {
|
||
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
|
||
let set: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": "No Such Press"}})).unwrap();
|
||
assert!(matches!(set.check(), Err(PdfError::BadSetting(m)) if m.contains("No Such Press")));
|
||
assert!(export(&d, &PdfOptions { settings: set, ..Default::default() }).is_err());
|
||
// Not converting, the destination doesn't matter.
|
||
let set: PdfSettings = serde_json::from_value(json!({"output": {"destination": "No Such Press"}})).unwrap();
|
||
assert_eq!(set.check(), Ok(()));
|
||
}
|
||
|
||
#[test]
|
||
fn profiles_tag_colours_with_icc_based_spaces() {
|
||
let d = doc(ColorMode::Rgb, &[Color::rgb(0.2, 0.4, 0.6), Color::cmyk(0.1, 0.2, 0.3, 0.4), Color::Gray { k: 0.5 }]);
|
||
let none = pdf(&d, json!({}));
|
||
assert!(icc_profiles(&none.bytes).is_empty());
|
||
assert!(colours(&none.bytes).iter().all(|c| c.0 != "scn"));
|
||
let all = pdf(&d, json!({"output": {"profiles": "all"}}));
|
||
let profiles = icc_profiles(&all.bytes);
|
||
let mut n: Vec<i32> = profiles.iter().map(|p| p.0).collect();
|
||
n.sort();
|
||
assert_eq!(n, [1, 3, 4], "grey, RGB and CMYK spaces");
|
||
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(&cms::active_settings().cmyk).unwrap()), "the working CMYK profile");
|
||
assert!(colours(&all.bytes).iter().all(|c| c.0 == "scn"), "{:?}", colours(&all.bytes));
|
||
// The destination profile tags converted colours.
|
||
let dest = pdf(&d, json!({"output": {"conversion": "destination", "destination": DEVICE_CMYK, "profiles": "destination"}}));
|
||
let profiles = icc_profiles(&dest.bytes);
|
||
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(DEVICE_CMYK).unwrap()), "the destination");
|
||
// Tagged source profiles: only a document that was assigned profiles is tagged.
|
||
assert!(icc_profiles(&pdf(&d, json!({"output": {"profiles": "taggedSource"}})).bytes).is_empty());
|
||
let mut tagged = d.clone();
|
||
tagged.color_profiles.cmyk = Some(GENERIC_CMYK.into());
|
||
assert_eq!(icc_profiles(&pdf(&tagged, json!({"output": {"profiles": "taggedSource"}})).bytes).len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn tagged_cmyk_documents_blend_in_the_cmyk_profile_space() {
|
||
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(1.0, 0.0, 0.0, 0.0)]);
|
||
let mut n = Node::path(
|
||
d.alloc_id(),
|
||
shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)),
|
||
Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||
);
|
||
n.opacity = 0.5;
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 1, n).unwrap();
|
||
let r = pdf(&d, json!({"output": {"profiles": "all"}}));
|
||
let text = String::from_utf8_lossy(&r.bytes);
|
||
let group = text.split("/Group<<").nth(1).unwrap();
|
||
let cs: i32 = group.split("/CS ").nth(1).unwrap().split(' ').next().unwrap().parse().unwrap();
|
||
let space = text.split(&format!("\n{cs} 0 obj\n")).nth(1).unwrap();
|
||
let stream: i32 = space.strip_prefix("[/ICCBased ").unwrap().split(' ').next().unwrap().parse().unwrap();
|
||
let file = Pdf::new(Arc::new(r.bytes.clone())).unwrap();
|
||
assert_eq!(file.xref().get::<Stream<'_>>(ObjectIdentifier::new(stream, 0)).unwrap().dict().get::<i32>(b"N"), Some(4), "{group}");
|
||
}
|
||
|
||
/// A 4 × 4 PNG of one colour.
|
||
fn png(rgb: [u8; 3]) -> Vec<u8> {
|
||
let mut out = Vec::new();
|
||
image::DynamicImage::from(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb)))
|
||
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
|
||
.unwrap();
|
||
out
|
||
}
|
||
|
||
fn image_doc(bytes: Vec<u8>) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
d.images.insert("img".into(), ImageBlob::new("image/png", bytes));
|
||
let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() };
|
||
let n = Node::new(d.alloc_id(), NodeKind::Image(image));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
}
|
||
|
||
/// The colour of the first image of the PDF `bytes` imports as, at its first pixel.
|
||
fn first_pixel(bytes: &[u8]) -> [u8; 3] {
|
||
let back = import(bytes).unwrap();
|
||
let blob = back.images.values().next().unwrap();
|
||
image::load_from_memory(&blob.bytes).unwrap().to_rgb8().get_pixel(0, 0).0
|
||
}
|
||
|
||
#[test]
|
||
fn images_are_converted_too() {
|
||
let d = image_doc(png([200, 40, 30]));
|
||
let plain = pdf(&d, json!({}));
|
||
assert!(String::from_utf8_lossy(&plain.bytes).contains("/ColorSpace/DeviceRGB"));
|
||
let r = pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": GENERIC_CMYK}}));
|
||
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceCMYK"), "a CMYK image");
|
||
// CMYK images aren't written as JPEG.
|
||
let jpeg =
|
||
pdf(&d, json!({"compression": {"color": {"compression": "jpeg"}}, "output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
|
||
assert!(!String::from_utf8_lossy(&jpeg.bytes).contains("/DCTDecode"));
|
||
assert!(jpeg.warnings.iter().any(|w| w.contains("ZIP")), "{:?}", jpeg.warnings);
|
||
// An RGB destination other than sRGB changes the pixels (unless their numbers are kept).
|
||
assert_eq!(first_pixel(&plain.bytes), [200, 40, 30]);
|
||
let p3 = |conversion: &str| first_pixel(&pdf(&d, json!({"output": {"conversion": conversion, "destination": cms::DISPLAY_P3}})).bytes);
|
||
assert_ne!(p3("destination"), [200, 40, 30]);
|
||
assert_eq!(p3("preserveNumbers"), [200, 40, 30]);
|
||
// Grey images stay grey.
|
||
let grey = image_doc(png([90, 90, 90]));
|
||
let r = pdf(&grey, json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
|
||
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceGray"));
|
||
}
|
||
|
||
#[test]
|
||
fn printers_marks_keep_their_inks() {
|
||
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
|
||
let r = pdf(&d, json!({"marks": {"colorBars": true}, "output": {"conversion": "destination", "destination": SRGB}}));
|
||
let ops: Vec<String> = colours(&r.bytes).into_iter().map(|c| c.0).collect();
|
||
assert!(ops.contains(&"rg".to_string()) && ops.contains(&"k".to_string()), "art converted, colour bars kept: {ops:?}");
|
||
}
|
||
|
||
/// The catalog's output intent of `bytes` (opened with `password`).
|
||
fn output_intent(bytes: &[u8], password: Option<&str>, f: impl FnOnce(&Dict<'_>)) {
|
||
let file = crate::pages::open(bytes, password).unwrap();
|
||
let xref = file.xref();
|
||
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
|
||
let intents = catalog.get::<Array<'_>>(b"OutputIntents").unwrap();
|
||
f(&intents.iter::<Dict<'_>>().next().unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn the_output_intent_embeds_its_profile_and_trapped_is_written() {
|
||
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
|
||
let out = json!({"outputIntent": GENERIC_CMYK, "outputCondition": "Coated (press)", "outputConditionId": "Généric", "registry": "http://example.com", "trapped": true});
|
||
for (title, password) in [("Poster", None), ("", None), ("Poster", Some("secret"))] {
|
||
d.title = title.into();
|
||
let mut set = json!({"output": out});
|
||
if let Some(p) = password {
|
||
set["security"] = json!({"openPassword": p});
|
||
}
|
||
let r = pdf(&d, set);
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
output_intent(&r.bytes, password, |oi| {
|
||
assert_eq!(oi.get::<Name<'_>>(b"S").unwrap().as_ref(), b"GTS_PDFX");
|
||
let text = |k: &[u8]| oi.get::<hayro_syntax::object::String<'_>>(k).map(|s| s.as_bytes().to_vec());
|
||
assert_eq!(text(b"OutputCondition").unwrap(), b"Coated (press)");
|
||
assert_eq!(text(b"RegistryName").unwrap(), b"http://example.com");
|
||
// Non-ASCII text is UTF-16 with its byte order mark.
|
||
let id: Vec<u8> = [0xFE, 0xFF].into_iter().chain("Généric".encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||
assert_eq!(text(b"OutputConditionIdentifier").unwrap(), id);
|
||
let profile = oi.get::<Stream<'_>>(b"DestOutputProfile").unwrap();
|
||
assert_eq!(profile.dict().get::<i32>(b"N"), Some(4));
|
||
assert_eq!(profile.decoded().unwrap().as_ref(), &*cms::icc_bytes(GENERIC_CMYK).unwrap());
|
||
});
|
||
if password.is_none() {
|
||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/True"), "title {title:?}");
|
||
}
|
||
assert_eq!(
|
||
import_with_report(&r.bytes, &ImportOptions { password: password.map(str::to_string), ..Default::default() })
|
||
.unwrap()
|
||
.document
|
||
.artboards
|
||
.len(),
|
||
1
|
||
);
|
||
}
|
||
// A condition identifier alone (a registered printing condition) names no profile; untrapped
|
||
// output says so.
|
||
let r = pdf(&d, json!({"output": {"outputConditionId": "CGATS TR 001"}}));
|
||
output_intent(&r.bytes, None, |oi| assert!(oi.get::<Stream<'_>>(b"DestOutputProfile").is_none()));
|
||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
|
||
// Nothing asked, nothing written.
|
||
let plain = String::from_utf8_lossy(&pdf(&d, json!({})).bytes).into_owned();
|
||
assert!(!plain.contains("/OutputIntents") && !plain.contains("/Trapped"));
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_a_keeps_its_own_output_intent() {
|
||
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
|
||
let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}}));
|
||
let text = String::from_utf8_lossy(&r.bytes);
|
||
assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped"));
|
||
assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn pdf_a_tags_cmyk_colours_with_the_cmyk_profile() {
|
||
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
|
||
let r = pdf(&d, json!({"standard": "pdfA2b"}));
|
||
assert!(icc_profiles(&r.bytes).iter().any(|p| p.0 == 4));
|
||
}
|