Print color: CMYK print output through craft-pdf's print settings

CMYK print exports (a CMYK blend space, and PDF/X-4) take their blending
groups, output intent and /Trapped from write::print::settings instead of
rewriting krilla's bytes. The output intent is the CMYK working space,
Coated FOGRA39 when it has none or its profile can't be written. PDF/X-4
still adds the XMP metadata date and version ID krilla doesn't write.
RGB and PDF/A exports are written as before.
This commit is contained in:
aiongg
2026-10-20 12:48:50 +00:00
parent ce5e58e63b
commit 6a04039f5f
10 changed files with 266 additions and 207 deletions
Generated
+1
View File
@@ -1142,6 +1142,7 @@ dependencies = [
name = "designcraft-pdf"
version = "0.9.0"
dependencies = [
"craft-cms",
"craft-pdf",
"designcraft-color",
"designcraft-compose",
+1 -1
View File
@@ -63,7 +63,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK and carry the CMYK working space as a `GTS_PDFX` output intent with `/Trapped /False`; RGB exports give pages with transparency a DeviceRGB page group. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK).
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.
+8 -5
View File
@@ -732,10 +732,11 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let doc: &designcraft_doc::Document = &boxed;
let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let mut r = r;
// Transparency Blend Space: the page group of pages with transparency.
// Transparency Blend Space: the page group of pages with transparency. CMYK print output
// gets its CMYK page groups from the writer.
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
let blended: Vec<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();
if blended.iter().any(|b| *b) {
if !designcraft_pdf::print_color(doc, &opts) && blended.iter().any(|b| *b) {
match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) {
Some(b) => r.bytes = b,
None => r.warnings.push("the transparency blend space couldn't be set".into()),
@@ -1248,8 +1249,8 @@ mod print_group_tests {
s
}
/// The placed PDF's transparency group blended in DeviceRGB in a print document
/// (`/Group<</Type/Group/S/Transparency/I true/CS/DeviceRGB>>`).
/// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a
/// screen one.
#[test]
fn print_transparency_groups_blend_in_cmyk() {
let mut s = page_with(&logo());
@@ -1258,7 +1259,9 @@ mod print_group_tests {
String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned()
};
let t = text(&mut s);
assert!(t.contains("/S/Transparency/I true/CS/DeviceCMYK>>"), "CMYK group");
// The placed page's group inherits the page group, which blends in CMYK.
assert!(t.contains("/Group<</Type/Group/S/Transparency/I true>>"), "placed page group without a blending space");
assert!(t.contains("/Group<</Type/Group/S/Transparency/CS/DeviceCMYK>>"), "CMYK page group");
assert!(!t.contains("DeviceRGB"), "no RGB left");
assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes()));
// The file still reads, with the same page.
+1
View File
@@ -15,6 +15,7 @@ designcraft-doc = { workspace = true }
designcraft-fonts = { workspace = true }
designcraft-compose = { workspace = true }
craft-pdf = { workspace = true, features = ["write-pdf"] }
craft-cms = { workspace = true }
krilla-svg = { workspace = true }
designcraft-images = { workspace = true }
image = { workspace = true }
+31 -17
View File
@@ -129,6 +129,13 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
bleed.iter().copied().fold(opts.marks.offset.max(0.0), f64::max)
}
/// Whether an export is CMYK print output: transparency blends in CMYK and the document's CMYK
/// working space is the output intent. True when the document blends in CMYK, and always in
/// PDF/X; PDF/A exports are RGB.
pub fn print_color(doc: &Document, opts: &PdfOptions) -> bool {
opts.standard != Standard::PdfA2b && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4)
}
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
@@ -136,14 +143,22 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
let sheets = sheets(doc, opts)?;
let warnings = Vec::new();
let (version, archival) = match opts.standard {
Standard::None => (PdfVersion::Pdf17, None),
Standard::PdfX4 => (PdfVersion::Pdf16, None),
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
};
let configuration = write::configuration(version, archival)?;
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
let mut warnings = Vec::new();
let base = if print_color(doc, opts) {
let (settings, warning) = crate::pdfx::print_settings()?;
warnings.extend(warning);
let pdfx_version = (opts.standard == Standard::PdfX4).then(|| "PDF/X-4".to_string());
krilla::SerializeSettings { pdfx_version, ..settings }
} else {
krilla::SerializeSettings::default()
};
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let created = opts.created.or_else(now_unix);
let metadata = Metadata {
@@ -228,13 +243,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
let mut warnings = ex.warnings;
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) {
// The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any
// group that got past it, with its page.
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
// Screen annotations aren't allowed in PDF/A.
@@ -253,10 +262,10 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
}
}
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title, created) {
// The XMP dates krilla doesn't write, then check the result.
match crate::pdfx::make_pdfx4(&bytes, created) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
None => warnings.push("PDF/X-4: the XMP metadata date couldn't be added".into()),
}
for issue in crate::pdfx::check_pdfx4(&bytes) {
warnings.push(format!("PDF/X-4: {issue}"));
@@ -339,7 +348,15 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
return Err(PdfError::NoPages);
}
let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() };
let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() };
let mut warnings = Vec::new();
let base = if print_color(doc, &pdf_opts) {
let (settings, warning) = crate::pdfx::print_settings()?;
warnings.extend(warning);
settings
} else {
krilla::SerializeSettings::default()
};
let settings = krilla::SerializeSettings { compress_content_streams: true, ..base };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() };
let mut writer = Writer::new(settings, metadata);
@@ -350,7 +367,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
cache,
opts: &pdf_opts,
clip: Rect::ZERO,
warnings: Vec::new(),
warnings,
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
@@ -405,10 +422,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
page.finish();
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk {
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
let bytes = writer.finish().map_err(PdfError::from)?;
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, pages: pairs.len(), warnings })
+6 -2
View File
@@ -37,8 +37,10 @@ mod text;
mod forms;
mod pdfx;
mod transitions;
pub use export::{BookletKind, BookletOptions, booklet_pairs, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs, sheet_spreads};
pub use pdfx::{check_pdfx4, cmyk_group_spaces, has_rgb_groups, make_pdfx4};
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 transitions::{add_blend_space, add_catalog_entries, add_page_entries, add_transitions};
/// PDF standard to target.
@@ -206,6 +208,8 @@ impl From<craft_pdf::write::WriteError> for PdfError {
}
}
#[cfg(test)]
mod print_tests;
#[cfg(test)]
mod rtl_tests;
#[cfg(test)]
+82 -178
View File
@@ -1,6 +1,7 @@
//! PDF/X-4: the output intent (the working CMYK space's profile), the PDF/X version in the document
//! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a
//! check of the requirements this exporter is responsible for.
//! CMYK print output: the serialization settings (the working CMYK space's profile as the output
//! intent, CMYK blending), the XMP dates PDF/X-4 requires, written as an incremental update of
//! krilla's PDF 1.6 output, and a check of the PDF/X-4 requirements this exporter is responsible
//! for.
use std::collections::HashSet;
@@ -8,141 +9,98 @@ use hayro_syntax::content::UntypedIter;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier, Stream};
use hayro_syntax::page::Resources;
use craft_pdf::write::WriteError;
use craft_pdf::write::backend as krilla;
use craft_pdf::write::print::{self, PrintColor};
use crate::transitions::{find, int_after, object, rfind};
/// Escape a PDF literal string.
fn lit(s: &str) -> String {
let mut o = String::from("(");
for c in s.chars() {
if matches!(c, '(' | ')' | '\\') {
o.push('\\');
}
o.push(c);
}
o.push(')');
o
/// The serialization settings for CMYK print output: CMYK blending groups, the document's CMYK
/// working space as the `GTS_PDFX` output intent (Coated FOGRA39 when it has none or its profile
/// can't be written) and `/Trapped /False`. The second value is a warning when the working
/// space's profile was replaced.
pub(crate) fn print_settings() -> crate::Result<(krilla::SerializeSettings, Option<String>)> {
print_settings_with(&designcraft_color::cms::active())
}
/// Turn krilla's output into PDF/X-4. `date` (Unix seconds, UTC) is the XMP metadata date; `None`
/// (no clock, as on wasm) writes the epoch, because PDF/X-4 requires the entry. `None` back when
/// the file isn't the simple kind this understands (a classic cross-reference table and plain
/// dictionaries).
pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option<i64>) -> Option<Vec<u8>> {
/// [`print_settings`] for the working spaces of `cms`.
pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms) -> crate::Result<(krilla::SerializeSettings, Option<String>)> {
let fogra39 = || print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC));
let Ok(intent) = cms.output_intent() else { return Ok((fogra39()?, None)) };
let registered = intent.identifier != "Custom";
let color = PrintColor {
profile: &intent.icc,
condition_identifier: intent.identifier,
condition: Some(&intent.condition),
registry_name: registered.then_some("http://www.color.org"),
info: Some(&intent.condition),
};
match print::settings(color) {
Ok(settings) => Ok((settings, None)),
Err(WriteError::InvalidProfile) => Ok((
fogra39()?,
Some(format!("the CMYK working space `{}` couldn't be written as the output intent; Coated FOGRA39 was used", intent.condition)),
)),
Err(e) => Err(e.into()),
}
}
/// Finish krilla's PDF/X-4 output with the XMP entries PDF/X-4 requires and krilla doesn't write:
/// `xmp:MetadataDate` and `xmpMM:VersionID`, as an incremental update. `date` (Unix seconds, UTC)
/// is the metadata date; `None` (no clock, as on wasm) writes the epoch. `None` back when the
/// file isn't the simple kind this understands (a classic cross-reference table, uncompressed
/// XMP).
pub fn make_pdfx4(pdf: &[u8], date: Option<i64>) -> Option<Vec<u8>> {
let t_at = rfind(pdf, b"trailer")?;
let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string();
let trailer = String::from_utf8_lossy(pdf.get(t_at..)?).to_string();
let size = int_after(&trailer, "/Size")?;
let root = int_after(&trailer, "/Root")?;
let prev = int_after(&trailer, "startxref")?;
let info = int_after(&trailer, "/Info");
let id = trailer.find("/ID").and_then(|i| trailer[i..].find(']').map(|j| trailer[i..i + j + 1].to_string()));
let cat = String::from_utf8_lossy(&pdf[object(pdf, root)?]).to_string();
let info = int_after(&trailer, "/Info").map(|i| format!("/Info {i} 0 R")).unwrap_or_default();
let id = trailer.find("/ID").and_then(|i| trailer.get(i..)?.find(']').and_then(|j| trailer.get(i..i + j + 1)).map(str::to_string));
let cat = String::from_utf8_lossy(pdf.get(object(pdf, root)?)?).to_string();
let meta = int_after(&cat, "/Metadata")?;
let body = pdf.get(object(pdf, meta)?)?;
let s0 = find(body, b"stream", 0)? + b"stream".len();
let s0 = s0 + body.get(s0..)?.iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
let s1 = rfind(body, b"endstream")?;
let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string();
let dict = String::from_utf8_lossy(body.get(..find(body, b"stream", 0)?)?).to_string();
if dict.contains("/Filter") || !xml.contains("</rdf:RDF>") {
return None;
}
let (y, mo, d, h, mi, se) = crate::export::civil(date.unwrap_or(0));
let stamp = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z");
let dates = format!(
"<rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\
<xmp:MetadataDate>{stamp}</xmp:MetadataDate><xmpMM:VersionID>1</xmpMM:VersionID></rdf:Description>"
);
let xml = xml.replacen("</rdf:RDF>", &format!("{dates}</rdf:RDF>"), 1);
let dict = {
let i = dict.find("/Length")?;
let rest = dict.get(i + 7..)?.trim_start();
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
format!("{}/Length {}{}", dict.get(..i)?, xml.len(), rest.get(end..)?)
};
let mut out = pdf.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let mut entries: Vec<(usize, usize)> = Vec::new();
let mut put = |out: &mut Vec<u8>, id: usize, body: &[u8]| {
entries.push((id, out.len()));
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
// The output profile and the output intent.
let intent = designcraft_color::cms::active().output_intent().ok()?;
let (icc_id, oi_id) = (size, size + 1);
let mut s = format!("<</N 4/Length {}>>\nstream\n", intent.icc.len()).into_bytes();
s.extend_from_slice(&intent.icc);
s.extend_from_slice(b"\nendstream");
put(&mut out, icc_id, &s);
let cond = &intent.condition;
put(
&mut out,
oi_id,
format!(
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}/OutputCondition{}/Info{}/RegistryName(http://www.color.org)/DestOutputProfile {icc_id} 0 R>>",
lit(intent.identifier),
lit(cond),
lit(cond)
)
.as_bytes(),
);
// The catalog with the intent.
let close = cat.rfind(">>")?;
let cat_new = format!("{}/OutputIntents[{oi_id} 0 R]{}", cat[..close].trim(), &cat[close..]);
put(&mut out, root, cat_new.trim().as_bytes());
// Document information: the PDF/X version and trapping state.
let mut next = size + 2;
let info_id = match info {
Some(i) => {
let body = String::from_utf8_lossy(&pdf[object(pdf, i)?]).to_string();
let close = body.rfind(">>")?;
put(&mut out, i, format!("{}/GTS_PDFXVersion(PDF/X-4)/Trapped/False{}", body[..close].trim(), &body[close..]).trim().as_bytes());
i
}
None => {
let i = next;
next += 1;
put(&mut out, i, format!("<</Title{}/GTS_PDFXVersion(PDF/X-4)/Trapped/False>>", lit(title)).as_bytes());
i
}
};
// XMP: the PDF/X identification schema, the trapping state that Info also states, and the
// metadata date and version ID that PDF/X-4 requires.
if let Some(meta) = int_after(&cat, "/Metadata") {
let r = object(pdf, meta)?;
let body = &pdf[r];
let s0 = find(body, b"stream", 0)? + b"stream".len();
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
let s1 = rfind(body, b"endstream")?;
let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
if dict.contains("/Filter") {
return None;
}
let (y, mo, d, h, mi, se) = crate::export::civil(date.unwrap_or(0));
let stamp = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z");
let add = concat!(
"<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>",
"<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Trapped>False</pdf:Trapped></rdf:Description>"
);
let dates = format!(
"<rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\
<xmp:MetadataDate>{stamp}</xmp:MetadataDate><xmpMM:VersionID>1</xmpMM:VersionID></rdf:Description>"
);
let xml = xml.replacen("</rdf:RDF>", &format!("{add}{dates}</rdf:RDF>"), 1);
let dict = {
let i = dict.find("/Length")?;
let rest = &dict[i + 7..];
let rest = rest.trim_start();
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
format!("{}/Length {}{}", &dict[..i], xml.len(), &rest[end..])
};
let mut b = dict.trim().as_bytes().to_vec();
b.extend_from_slice(b"\nstream\n");
b.extend_from_slice(xml.as_bytes());
b.extend_from_slice(b"\nendstream");
put(&mut out, meta, &b);
}
// Cross-reference section and trailer.
let at = out.len();
out.extend_from_slice(format!("{meta} 0 obj\n").as_bytes());
out.extend_from_slice(dict.trim().as_bytes());
out.extend_from_slice(b"\nstream\n");
out.extend_from_slice(xml.as_bytes());
out.extend_from_slice(b"\nendstream\nendobj\n");
let xref_at = out.len();
entries.sort();
let mut x = String::from("xref\n");
for (oid, off) in &entries {
x.push_str(&format!("{oid} 1\n{off:010} 00000 n\r\n"));
}
let id = id.unwrap_or_default();
x.push_str(&format!("trailer\n<</Size {next}/Root {root} 0 R/Info {info_id} 0 R{id}/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n"));
out.extend_from_slice(x.as_bytes());
out.extend_from_slice(
format!("xref\n{meta} 1\n{at:010} 00000 n\r\ntrailer\n<</Size {size}/Root {root} 0 R{info}{id}/Prev {prev}>>\nstartxref\n{xref_at}\n%%EOF\n")
.as_bytes(),
);
Some(out)
}
/// krilla gives every transparency group it writes (placed PDF pages, opacity, isolated
/// blending) a DeviceRGB blending space. In a print document that puts DeviceRGB in a CMYK file
/// (PDF/X-4 forbids it under a CMYK output intent) and blends CMYK art through RGB, which can
/// turn a K-only black into a four-colour black. This rewrites those group dictionaries to
/// DeviceCMYK in place and at the same length (the optional `/Type/Group` gives the room), so
/// every cross-reference offset stays valid. Only object dictionaries are touched, never stream
/// data. Returns how many groups changed.
/// Whether a transparency group dictionary still names DeviceRGB as its blending space.
pub fn has_rgb_groups(pdf: &[u8]) -> bool {
pdf.windows(b"/CS/DeviceRGB".len()).enumerate().any(|(i, w)| {
@@ -150,61 +108,6 @@ pub fn has_rgb_groups(pdf: &[u8]) -> bool {
})
}
pub fn cmyk_group_spaces(pdf: &mut [u8]) -> usize {
const FORMS: [(&[u8], &[u8]); 2] = [
(b"/Group<</Type/Group/S/Transparency/I true/CS/DeviceRGB>>", b"/Group<</S/Transparency/I true/CS/DeviceCMYK>>"),
(b"/Group<</Type/Group/S/Transparency/CS/DeviceRGB>>", b"/Group<</S/Transparency/CS/DeviceCMYK>>"),
];
let Some(dicts) = dictionary_ranges(pdf) else { return 0 };
let mut changed = 0;
for r in dicts {
for (from, to) in FORMS {
let mut at = r.start;
while let Some(i) = find(pdf.get(..r.end).unwrap_or_default(), from, at) {
if let Some(dst) = pdf.get_mut(i..i + from.len()) {
dst.fill(b' ');
if let Some(head) = dst.get_mut(..to.len()) {
head.copy_from_slice(to);
changed += 1;
}
}
at = i + from.len();
}
}
}
changed
}
/// Byte ranges of the dictionary part of every object in a file with one classic
/// cross-reference table (krilla's output): from the object's offset to its `stream` keyword or
/// `endobj`. `None` when the table can't be read.
fn dictionary_ranges(pdf: &[u8]) -> Option<Vec<std::ops::Range<usize>>> {
let tail = String::from_utf8_lossy(pdf.get(rfind(pdf, b"startxref")?..)?).to_string();
let xref = int_after(&tail, "startxref")?;
let table = pdf.get(xref..)?;
if !table.starts_with(b"xref") {
return None;
}
let end = find(table, b"trailer", 0)?;
let text = String::from_utf8_lossy(table.get(4..end)?).to_string();
let mut words = text.split_ascii_whitespace();
let mut out = Vec::new();
while let (Some(_first), Some(count)) = (words.next(), words.next()) {
let count: usize = count.parse().ok()?;
for _ in 0..count {
let (off, _gen, kind) = (words.next()?, words.next()?, words.next()?);
if kind != "n" {
continue;
}
let start: usize = off.parse().ok()?;
let endobj = find(pdf, b"endobj", start)?;
let stop = find(pdf.get(..endobj)?, b"stream", start).unwrap_or(endobj);
out.push(start..stop);
}
}
Some(out)
}
/// PDF/X-4 checks on an exported file: what's missing or not allowed, device RGB under the CMYK
/// output intent included (empty = passes these checks).
pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
@@ -613,7 +516,8 @@ mod tests {
let rgb: Vec<&String> = r.warnings.iter().filter(|w| w.contains("RGB")).collect();
assert_eq!(rgb.len(), 1, "{:?}", r.warnings);
assert!(rgb[0].starts_with("PDF/X-4: an RGB image (object ") && rgb[0].contains("4 × 2 px) on page 1:"), "{}", rgb[0]);
// Without a PDF/X output intent there is nothing to check RGB against.
// A screen export (RGB blend space) has no output intent to check RGB against.
d.settings.blend_space = designcraft_doc::BlendSpace::Rgb;
let plain = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(!check_pdfx4(&plain.bytes).iter().any(|i| i.contains("RGB")));
}
@@ -808,7 +712,7 @@ mod tests {
let catalog = xref.get::<hayro_syntax::object::Dict>(xref.root_id()).unwrap();
let xmp = catalog.get::<hayro_syntax::object::Stream>(b"Metadata").unwrap().decoded().unwrap().into_owned();
let xmp = String::from_utf8_lossy(&xmp);
assert!(xmp.contains("xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Trapped>False</pdf:Trapped>"), "{xmp}");
assert!(xmp.contains("xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"") && xmp.contains("<pdf:Trapped>False</pdf:Trapped>"), "{xmp}");
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
+133
View File
@@ -0,0 +1,133 @@
//! CMYK print output, read back from the exported PDF: blending spaces, the output intent,
//! overprint and knockout groups.
use designcraft_color::cms::{self, Cms, ColorSettings};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{BlendSpace, Document, Fill, Item, ItemId, SpreadRef};
use designcraft_geom::Rect;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, Name, Object, Stream};
use crate::{PdfOptions, export_pdf};
/// A box filled with `swatch`; `edit` sets its other attributes.
fn add_box(d: &mut Document, r: Rect, swatch: &str, edit: impl FnOnce(&mut Item)) -> ItemId {
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
b.fill = Fill::swatch(swatch);
edit(&mut b);
d.insert_item(SpreadRef::Doc(0), b, None).unwrap();
id
}
/// One page: a cyan box with a 50% black box over it at 50% opacity.
fn transparent_doc() -> Document {
let mut d = Document::new(&NewDocument::default());
add_box(&mut d, Rect::new(36.0, 36.0, 236.0, 236.0), "C=100 M=0 Y=0 K=0", |_| {});
add_box(&mut d, Rect::new(136.0, 136.0, 336.0, 336.0), "Black", |b| b.opacity = 0.5);
d
}
pub(crate) struct Intent {
pub identifier: String,
pub condition: String,
pub registry: Option<String>,
pub profile: Vec<u8>,
}
fn text(d: &Dict<'_>, key: &[u8]) -> Option<String> {
d.get::<hayro_syntax::object::String>(key).map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
}
/// The catalog's `GTS_PDFX` output intent.
pub(crate) fn output_intent(bytes: &[u8]) -> Option<Intent> {
let pdf = Pdf::new(bytes.to_vec()).ok()?;
let xref = pdf.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id())?;
let intents = catalog.get::<Array<'_>>(b"OutputIntents")?;
let oi = intents.iter::<Dict<'_>>().find(|oi| oi.get::<Name<'_>>(b"S").is_some_and(|s| s.as_ref() == b"GTS_PDFX"))?;
Some(Intent {
identifier: text(&oi, b"OutputConditionIdentifier")?,
condition: text(&oi, b"OutputCondition").unwrap_or_default(),
registry: text(&oi, b"RegistryName"),
profile: oi.get::<Stream<'_>>(b"DestOutputProfile")?.decoded().ok()?.into_owned(),
})
}
/// The `/CS` of each page's transparency group (`None` for a page without one).
fn page_group_spaces(bytes: &[u8]) -> Vec<Option<String>> {
let pdf = Pdf::new(bytes.to_vec()).unwrap();
pdf.pages()
.iter()
.map(|p| {
let g = p.raw().get::<Dict<'_>>(b"Group")?;
match g.get::<Object<'_>>(b"CS")? {
Object::Name(n) => Some(n.as_str().to_string()),
Object::Array(a) => a.iter::<Name<'_>>().next().map(|n| n.as_str().to_string()),
_ => None,
}
})
.collect()
}
/// Every form XObject's transparency group dictionary.
fn form_groups(bytes: &[u8]) -> Vec<(Option<String>, bool, bool)> {
let pdf = Pdf::new(bytes.to_vec()).unwrap();
pdf.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.filter(|s| s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Form"))
.filter_map(|s| {
let g = s.dict().get::<Dict<'_>>(b"Group")?;
let cs = g.get::<Name<'_>>(b"CS").map(|n| n.as_str().to_string());
Some((cs, g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)))
})
.collect()
}
/// Print output (a CMYK blend space) blends in DeviceCMYK and carries the CMYK working space
/// as its output intent; a screen document (RGB blend space) is written as before.
#[test]
fn print_output_blends_in_cmyk_with_the_working_space_as_output_intent() {
let mut d = transparent_doc();
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(page_group_spaces(&bytes), [Some("DeviceCMYK".to_string())]);
assert!(form_groups(&bytes).iter().all(|(cs, ..)| cs.is_none()), "content groups inherit the page group: {:?}", form_groups(&bytes));
assert!(!crate::has_rgb_groups(&bytes));
let intent = output_intent(&bytes).expect("output intent");
assert_eq!(intent.identifier, "FOGRA39");
assert_eq!(intent.profile, craft_cms::builtin::COATED_FOGRA39_ICC);
assert!(String::from_utf8_lossy(&bytes).contains("/Trapped/False"));
// A booklet of a print document too.
let booklet = crate::export_booklet(&d, &Cache::new(), &crate::BookletOptions::default()).unwrap();
assert!(page_group_spaces(&booklet.bytes).contains(&Some("DeviceCMYK".to_string())));
assert!(output_intent(&booklet.bytes).is_some());
d.settings.blend_space = BlendSpace::Rgb;
let screen = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(output_intent(&screen).is_none());
assert_eq!(page_group_spaces(&screen), [None]);
assert!(!String::from_utf8_lossy(&screen).contains("/Trapped"));
}
/// The output intent is the CMYK working space's profile and condition, not FOGRA39.
#[test]
fn output_intent_is_the_cmyk_working_space() {
use craft_pdf::write::backend as krilla;
let generic = Cms::new(&ColorSettings { cmyk: cms::GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap();
let (settings, warning) = crate::pdfx::print_settings_with(&generic).unwrap();
assert_eq!(warning, None);
let mut pdf = krilla::Document::new_with(settings);
let mut page = pdf.start_page_with(krilla::page::PageSettings::new(krilla::geom::Size::from_wh(100.0, 100.0).unwrap()));
page.surface().finish();
page.finish();
let bytes = pdf.finish().unwrap();
let intent = output_intent(&bytes).expect("output intent");
let want = generic.output_intent().unwrap();
assert_eq!((intent.identifier.as_str(), intent.condition.as_str()), ("Custom", cms::GENERIC_CMYK));
assert_eq!(intent.registry, None, "an unregistered condition names no registry");
assert_eq!(intent.profile, &*want.icc);
assert_ne!(intent.profile, craft_cms::builtin::COATED_FOGRA39_ICC);
}
+2 -3
View File
@@ -188,10 +188,9 @@ fn standards() {
// Output intent, identification and checks all in place; the update still reads.
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(crate::check_pdfx4(&r.bytes).is_empty());
let text = String::from_utf8_lossy(&r.bytes).to_string();
// The default working CMYK space is the built-in Coated FOGRA39.
assert!(text.contains("/OutputIntents[") && text.contains("/OutputConditionIdentifier(FOGRA39)"), "output intent");
assert!(text.contains(r"/OutputCondition(Coated FOGRA39 \(fitted, craft-cms\))"), "output condition");
let intent = crate::print_tests::output_intent(&r.bytes).expect("output intent");
assert_eq!((intent.identifier.as_str(), intent.condition.as_str()), ("FOGRA39", "Coated FOGRA39 (fitted, craft-cms)"));
// A plain export fails the checks; both read with the same pages.
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(hayro_syntax::Pdf::new(r.bytes.clone()).unwrap().pages().len(), hayro_syntax::Pdf::new(plain.clone()).unwrap().pages().len());
+1 -1
View File
@@ -318,7 +318,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only.
- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported.
- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open.
- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK and carry the CMYK working space as a `GTS_PDFX` output intent with `/Trapped /False`; RGB exports give pages with transparency a DeviceRGB page group. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open.
- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection).
- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans).
- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images.