Merge pull request #2751 from AlPaka1234/fix/2743-pdf-cmyk-profile

PDF export: attach working CMYK profile as /DefaultCMYK (#2743)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-14 14:37:17 +01:00
commit e230c4e406
11 files changed
+1399 -52

No files matched your search

+2 -1
View File
@@ -110,7 +110,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 blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); 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 colors 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 (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent; plain print, PDF/X-4, booklet and CMYK-containing RGB exports attach the working CMYK profile as `/DefaultCMYK`); 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 colors 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.
@@ -396,6 +396,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
- **2026-11-14** · IDML export keeps each endnote reference as its number in superscript text, as the endnote frame lists it, instead of dropping it; the endnotes are still written as plain text, without IDML's endnote elements (#2753). No readiness percentage change.
- **2026-11-14** · Object › Text Frame Options shows the inset spacing (one Inset for a non-rectangular frame), the column width and gutter and the paragraph spacing limit in the document's ruler units, as the Properties panel does, instead of always in picas, and a bare number typed in any of its measure fields is in those units (#2739). No readiness percentage change.
- **2026-11-13** · PDF exports outside PDF/X-1a and PDF/A-2b attach the CMYK working space's ICC profile as `/DefaultCMYK` (`[/ICCBased …]`) on every page, inherited `/Pages` resource dict, Form XObject, SMask form, tiling pattern, and Type3 font resource scope (#2743). Plain print exports and booklets embed one shared Flate-compressed `/N 4` profile stream without adding `/OutputIntents` or `/Trapped`; PDF/X-4 reuses its catalog `/DestOutputProfile`; RGB-blend documents attach `/DefaultCMYK` when they draw in `DeviceCMYK` and skip it for pure RGB. Viewers that honor `/DefaultCMYK` instead of `/OutputIntents` (including Apple Quartz and MuPDF) now render CMYK colors consistently with the working space across both plain and PDF/X-4 exports. No readiness percentage change.
- **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change.
- **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change.
- **2026-11-12** · Composing a story with endnotes, anchored objects or cross-references counts their marks in one pass over the story, instead of counting again from the story start for every paragraph. No readiness percentage change.
+33
View File
@@ -712,4 +712,37 @@ mod overset_tests {
let r = s.execute("file.exportPdf", &json!({"pages": [1], "spreads": true})).unwrap();
assert!(about(&r).is_empty(), "the cover's spread holds no overset frame: {r}");
}
/// Plain print exports attach `/DefaultCMYK` without `/OutputIntents`, PDF/X-4 keeps both
/// cleanly, and PDF/X-1a omits `/DefaultCMYK`.
#[test]
fn export_pdf_command_attaches_default_cmyk_according_to_standard() {
let mut s = Session::new();
s.execute("file.new", &json!({"intent": "print"})).unwrap();
s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
s.execute("swatch.create", &json!({"name": "Slate", "color": {"c": 42.5, "m": 17.5, "y": 16.0, "k": 7.1}})).unwrap();
s.execute("object.fill", &json!({"swatch": "Slate"})).unwrap();
let export = |s: &mut Session, extra: serde_json::Value| {
let res = s.execute("file.exportPdf", &extra).unwrap();
let raw = crate::cmd::base64_decode(res["base64"].as_str().unwrap());
(raw, res["warnings"].clone())
};
let (plain, plain_w) = export(&mut s, json!({"tagged": true}));
assert_eq!(plain_w, json!([]), "plain export warnings: {plain_w}");
let plain_text = String::from_utf8_lossy(&plain);
assert!(plain_text.contains("/DefaultCMYK ["), "plain export must carry /DefaultCMYK");
assert!(!plain_text.contains("/OutputIntents"), "plain export must not carry /OutputIntents");
let (x4, x4_w) = export(&mut s, json!({"standard": "x4"}));
assert_eq!(x4_w, json!([]), "PDF/X-4 warnings: {x4_w}");
let x4_text = String::from_utf8_lossy(&x4);
assert!(x4_text.contains("/DefaultCMYK ["), "PDF/X-4 export must carry /DefaultCMYK");
assert!(x4_text.contains("/OutputIntents"), "PDF/X-4 export must keep /OutputIntents");
let (x1a, _) = export(&mut s, json!({"standard": "x1a"}));
let x1a_text = String::from_utf8_lossy(&x1a);
assert!(!x1a_text.contains("/DefaultCMYK"), "PDF/X-1a must not carry ICCBased DefaultCMYK");
}
}
+1 -1
View File
@@ -24,6 +24,7 @@ image = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
flate2 = "1"
[dev-dependencies]
image = { workspace = true, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
@@ -31,5 +32,4 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em
hayro-cmap = "0.1"
kurbo = { workspace = true }
tiff = { workspace = true }
flate2 = "1"
designcraft-fonts = { workspace = true, features = ["testing"] }
File diff suppressed because it is too large. Load diff
+5 -4
View File
@@ -82,12 +82,12 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
}
let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() };
let mut warnings = Vec::new();
let base = if print_color(doc, &pdf_opts) {
let (settings, warning) = crate::pdfx::print_settings(false)?;
let (base, cmyk_icc) = if print_color(doc, &pdf_opts) {
let (settings, icc, warning) = crate::pdfx::print_settings_and_profile(false)?;
warnings.extend(warning);
settings
(settings, Some(icc))
} else {
krilla::SerializeSettings::default()
(krilla::SerializeSettings::default(), None)
};
let settings = krilla::SerializeSettings { compress_content_streams: true, ..base };
let metadata = file_info_metadata(doc, opts.title.as_deref(), None);
@@ -169,6 +169,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) ->
bytes = crate::optional_content::register(&bytes)?;
}
let mut warnings = ex.warnings;
bytes = crate::default_cmyk::attach(bytes, cmyk_icc.as_deref(), Standard::None, &mut warnings)?;
warnings.dedup();
Ok(ExportReport { bytes, pages: pairs.len(), warnings })
}
+8 -5
View File
@@ -186,14 +186,14 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
};
let configuration = write::configuration(version, archival)?;
let mut warnings = Vec::new();
let base = if print_color(doc, opts) {
let (base, cmyk_icc) = if print_color(doc, opts) {
// An output intent only for PDF/X, as in InDesign.
let (settings, warning) = crate::pdfx::print_settings(opts.standard.is_pdfx())?;
let (settings, icc, warning) = crate::pdfx::print_settings_and_profile(opts.standard.is_pdfx())?;
warnings.extend(warning);
let pdfx_version = (opts.standard == Standard::PdfX4).then(|| "PDF/X-4".to_string());
krilla::SerializeSettings { pdfx_version, ..settings }
(krilla::SerializeSettings { pdfx_version, ..settings }, Some(icc))
} else {
krilla::SerializeSettings::default()
(krilla::SerializeSettings::default(), None)
};
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
@@ -289,11 +289,14 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// The XMP dates krilla doesn't write, then check the result.
// The XMP dates krilla doesn't write, then check the result after DefaultCMYK is attached.
match crate::pdfx::make_pdfx4(&bytes, created) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the XMP metadata date couldn't be added".into()),
}
}
bytes = crate::default_cmyk::attach(bytes, cmyk_icc.as_deref(), opts.standard, &mut warnings)?;
if opts.standard == Standard::PdfX4 {
for issue in crate::pdfx::check_pdfx4(&bytes) {
warnings.push(format!("PDF/X-4: {issue}"));
}
+1
View File
@@ -43,6 +43,7 @@ mod links;
mod marks;
mod text;
mod default_cmyk;
mod forms;
mod optional_content;
mod pdfx;
+75 -28
View File
@@ -26,8 +26,13 @@ fn references(dict: &Dict<'_>, key: &[u8]) -> Result<Vec<ObjectIdentifier>> {
}
fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result<Vec<u8>> {
edited_with(dict, replacements, invalid)
}
/// Rewrites `dict`, replacing or inserting entries in `replacements` (an empty value deletes the key).
pub(crate) fn edited_with(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])], error: fn() -> PdfError) -> Result<Vec<u8>> {
let data = dict.data();
let body = data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(invalid)?;
let body = if data.is_empty() { b"".as_slice() } else { data.strip_prefix(b"<<").and_then(|d| d.strip_suffix(b">>")).ok_or_else(error)? };
let mut reader = Reader::new(body);
let mut out = b"<<".to_vec();
loop {
@@ -36,12 +41,12 @@ fn edited(dict: &Dict<'_>, replacements: &[(&[u8], &[u8])]) -> Result<Vec<u8>> {
break;
}
let start = reader.offset();
let name = reader.read_without_context::<Name<'_>>().ok_or_else(invalid)?;
let name = reader.read_without_context::<Name<'_>>().ok_or_else(error)?;
reader.skip_white_spaces_and_comments();
reader.skip::<MaybeRef<Object<'_>>>(false).ok_or_else(invalid)?;
reader.skip::<MaybeRef<Object<'_>>>(false).ok_or_else(error)?;
if !replacements.iter().any(|(key, _)| *key == name.as_ref()) {
out.push(b'\n');
out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(invalid)?);
out.extend_from_slice(body.get(start..reader.offset()).ok_or_else(error)?);
}
}
for (key, value) in replacements {
@@ -100,12 +105,12 @@ pub(crate) fn trailer_at(bytes: &[u8], offset: usize) -> Option<Dict<'_>> {
/// Whether `dict` has `key`: a literal `null` value is the same as no entry. A reference counts,
/// since a trailer has no context to resolve it in.
fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool {
pub(crate) fn has_entry(dict: &Dict<'_>, key: &[u8]) -> bool {
dict.contains_key(key) && !matches!(dict.get_raw::<Object<'_>>(key), Some(MaybeRef::NotRef(Object::Null(_))))
}
fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIdentifier) -> Result<Vec<u8>> {
let damaged = || PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into());
/// Validates the cross-reference chain, rejects encrypted trailers, and returns `(prev, trailer, size)`.
fn trailer_and_size<'a>(bytes: &'a [u8], damaged: fn() -> PdfError, encrypted: fn() -> PdfError) -> Result<(usize, Dict<'a>, i32)> {
let at = crate::transitions::rfind(bytes, b"startxref").ok_or_else(damaged)?;
let tail = std::str::from_utf8(bytes.get(at..).ok_or_else(damaged)?).map_err(|_| damaged())?;
let prev = crate::transitions::int_after(tail, "startxref").ok_or_else(damaged)?;
@@ -121,7 +126,7 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIde
}
let section = trailer_at(bytes, at).ok_or_else(damaged)?;
if has_entry(&section, b"Encrypt") {
return Err(invalid());
return Err(encrypted());
}
offset = section.get::<usize>(b"Prev");
if offset.is_none() && has_entry(&section, b"Prev") {
@@ -131,44 +136,86 @@ fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIde
if offset.is_some() {
return Err(damaged());
}
trailer.get::<i32>(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?;
let size = trailer.get::<i32>(b"Size").filter(|n| *n > 0).ok_or_else(damaged)?;
Ok((prev, trailer, size))
}
/// Validates an unencrypted PDF's trailer chain and returns its trailer `/Size`.
pub(crate) fn trailer_size(bytes: &[u8], damaged: fn() -> PdfError, encrypted: fn() -> PdfError) -> Result<i32> {
let (_, _, size) = trailer_and_size(bytes, damaged, encrypted)?;
Ok(size)
}
/// Appends updated or newly allocated objects as a classic incremental update, keeping `/Size`
/// at least `max_obj + 1` and rejecting encrypted PDFs.
pub(crate) fn append_with(
bytes: &[u8],
changes: &[(ObjectIdentifier, Vec<u8>)],
root: Option<ObjectIdentifier>,
damaged: fn() -> PdfError,
encrypted: fn() -> PdfError,
) -> Result<Vec<u8>> {
let (prev, trailer, old_size) = trailer_and_size(bytes, damaged, encrypted)?;
if changes.is_empty() {
return Ok(bytes.to_vec());
}
let mut unique = std::collections::BTreeMap::new();
for (id, body) in changes {
if id.obj_number <= 0 || u16::try_from(id.gen_number).is_err() {
return Err(damaged());
}
unique.insert(*id, body.as_slice());
}
let max_obj = unique.keys().next_back().map(|id| id.obj_number).unwrap_or(0);
let new_size = max_obj.checked_add(1).ok_or_else(damaged)?.max(old_size);
let mut out = bytes.to_vec();
out.push(b'\n');
let mut offsets = Vec::new();
for (id, body) in changes {
offsets.push((*id, out.len()));
let mut offsets = Vec::with_capacity(unique.len());
for (id, body) in unique {
offsets.push((id, out.len()));
out.extend_from_slice(format!("{} {} obj\n", id.obj_number, id.gen_number).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(b"xref\n");
offsets.sort_by_key(|(id, _)| *id);
for (id, offset) in offsets {
out.extend_from_slice(format!("{} 1\n{offset:010} {:05} n\r\n", id.obj_number, id.gen_number).as_bytes());
}
let root = format!("{} {} R", root.obj_number, root.gen_number);
let prev = prev.to_string();
let trailer = edited(
&trailer,
&[
(b"Root", root.as_bytes()),
(b"Prev", prev.as_bytes()),
(b"XRefStm", b""),
(b"Type", b""),
(b"W", b""),
(b"Index", b""),
(b"Length", b""),
(b"Filter", b""),
(b"DecodeParms", b""),
],
)?;
let size = new_size.to_string();
let root_ref = root.map(|r| format!("{} {} R", r.obj_number, r.gen_number));
let mut replacements: Vec<(&[u8], &[u8])> = vec![
(b"Size", size.as_bytes()),
(b"Prev", prev.as_bytes()),
(b"XRefStm", b""),
(b"Type", b""),
(b"W", b""),
(b"Index", b""),
(b"Length", b""),
(b"Filter", b""),
(b"DecodeParms", b""),
];
if let Some(root_ref) = root_ref.as_ref() {
replacements.push((b"Root", root_ref.as_bytes()));
}
let trailer = edited_with(&trailer, &replacements, damaged)?;
out.extend_from_slice(b"trailer\n");
out.extend_from_slice(&trailer);
out.extend_from_slice(format!("\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Ok(out)
}
fn append(bytes: &[u8], changes: &[(ObjectIdentifier, Vec<u8>)], root: ObjectIdentifier) -> Result<Vec<u8>> {
append_with(
bytes,
changes,
Some(root),
|| PdfError::Write("the placed PDF's cross-reference table is damaged; layer changes could not be preserved safely".into()),
invalid,
)
}
#[derive(Clone)]
pub(crate) struct Variant {
pub data: Arc<Vec<u8>>,
+30 -12
View File
@@ -18,19 +18,36 @@ use crate::transitions::{find, int_after, object, rfind};
mod fonts;
/// The serialization settings for CMYK print output: DeviceCMYK and CMYK blending groups in the
/// document's CMYK working space (Coated FOGRA39 when it has none or its profile can't be
/// written). With `intent` (PDF/X), the working space is also the `GTS_PDFX` output intent, with
/// `/Trapped /False`. The second value is a warning when the working space's profile was replaced.
pub(crate) fn print_settings(intent: bool) -> crate::Result<(krilla::SerializeSettings, Option<String>)> {
print_settings_with(&designcraft_color::cms::active(), intent)
/// [`print_settings_and_profile`] without the ICC bytes, kept for tests.
#[cfg(test)]
pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: bool) -> crate::Result<(krilla::SerializeSettings, Option<String>)> {
let (settings, _, warning) = print_settings_and_profile_with(cms, intent)?;
Ok((settings, warning))
}
/// [`print_settings`] for the working spaces of `cms`.
pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: bool) -> crate::Result<(krilla::SerializeSettings, Option<String>)> {
/// The serialization settings for CMYK print output: DeviceCMYK and CMYK blending groups in the
/// document's CMYK working space (Coated FOGRA39 when it has none or its profile can't be
/// written), together with the validated ICC profile bytes actually used. With `intent` (PDF/X),
/// the working space is also the `GTS_PDFX` output intent, with `/Trapped /False`. The third
/// value is a warning when the working space's profile was replaced.
pub(crate) fn print_settings_and_profile(intent: bool) -> crate::Result<(krilla::SerializeSettings, Vec<u8>, Option<String>)> {
print_settings_and_profile_with(&designcraft_color::cms::active(), intent)
}
/// [`print_settings_and_profile`] for the working spaces of `cms`.
pub(crate) fn print_settings_and_profile_with(
cms: &designcraft_color::cms::Cms,
intent: bool,
) -> crate::Result<(krilla::SerializeSettings, Vec<u8>, Option<String>)> {
let write = |color: PrintColor<'_>| if intent { print::settings(color) } else { print::cmyk_blending(color.profile) };
let fogra39 = || write(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC));
let Ok(space) = cms.output_intent() else { return Ok((fogra39()?, None)) };
let fogra39 = || {
let profile = craft_cms::builtin::COATED_FOGRA39_ICC;
Ok::<_, crate::PdfError>((write(PrintColor::fogra39(profile))?, profile.to_vec()))
};
let Ok(space) = cms.output_intent() else {
let (settings, icc) = fogra39()?;
return Ok((settings, icc, None));
};
let registered = space.identifier != "Custom";
let color = PrintColor {
profile: &space.icc,
@@ -40,9 +57,10 @@ pub(crate) fn print_settings_with(cms: &designcraft_color::cms::Cms, intent: boo
info: Some(&space.condition),
};
match write(color) {
Ok(settings) => Ok((settings, None)),
Ok(settings) => Ok((settings, space.icc.into_owned(), None)),
Err(WriteError::InvalidProfile) => {
Ok((fogra39()?, Some(format!("the CMYK working space `{}` couldn't be written to the PDF; Coated FOGRA39 was used", space.condition))))
let (settings, icc) = fogra39()?;
Ok((settings, icc, Some(format!("the CMYK working space `{}` couldn't be written to the PDF; Coated FOGRA39 was used", space.condition))))
}
Err(e) => Err(e.into()),
}
+214
View File
@@ -287,3 +287,217 @@ fn knockout_and_isolation_are_group_attributes() {
assert_eq!(attributes(grouped(true, true)), [(true, true)]);
assert_eq!(attributes(grouped(false, false)), []);
}
/// Read the `/DefaultCMYK` ICC stream (object id and decoded profile bytes) from `res`.
fn default_cmyk_profile(res: &craft_pdf::syntax::page::Resources<'_>) -> Option<(i32, i32, Vec<u8>)> {
let cs = res.get_color_space(&Name::new_unescaped(b"DefaultCMYK"))?.into_array()?;
let mut items = cs.iter::<Object<'_>>();
let name = items.next()?.into_name()?;
if name.as_ref() != b"ICCBased" {
return None;
}
let icc = items.next()?.into_stream()?;
let n = icc.dict().get::<i32>(b"N")?;
let id = icc.obj_id().obj_number;
let decoded = icc.decoded().ok()?.into_owned();
Some((id, n, decoded))
}
/// Plain print exports, PDF/X-4 and Print Booklet attach the working CMYK profile as `/DefaultCMYK`
/// on every page and form XObject without adding `/OutputIntents` outside PDF/X; PDF/X-1a and
/// PDF/A-2b leave `/DefaultCMYK` out.
#[test]
fn default_cmyk_profile_is_written_where_appropriate() {
use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{Color, Swatch};
let mut d = transparent_doc();
d.swatches.push(Swatch::color("Card Blue", Color::cmyk(0.425, 0.175, 0.16, 0.071)));
add_box(&mut d, Rect::new(20.0, 20.0, 100.0, 100.0), "Card Blue", |_| {});
d.swatches.push(Swatch {
name: "Spot Gold".into(),
value: SwatchValue::Color { color: Color::cmyk(0.0, 0.3, 1.0, 0.0), color_type: ColorType::Spot },
locked: false,
named: true,
hidden: false,
});
add_box(&mut d, Rect::new(40.0, 40.0, 120.0, 120.0), "Spot Gold", |_| {});
let expected = craft_cms::builtin::COATED_FOGRA39_ICC;
// 1. Plain export (Standard::None): no OutputIntents / Trapped, but /DefaultCMYK on page and form XObjects.
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(output_intent(&plain).is_none());
assert!(!String::from_utf8_lossy(&plain).contains("/OutputIntents"));
let pdf = Pdf::new(plain.clone()).expect("parse plain");
let page_res = pdf.pages()[0].resources();
let (icc_id, n, prof) = default_cmyk_profile(page_res).expect("page /DefaultCMYK in plain export");
assert_eq!(n, 4);
assert_eq!(prof, expected);
// Spot Separation resource is kept alongside /DefaultCMYK.
assert!(!page_res.color_spaces.is_empty());
let has_separation = page_res.color_spaces.entries().any(|(k, _)| k.as_ref() != b"DefaultCMYK");
assert!(has_separation, "spot color space entry must be preserved alongside DefaultCMYK");
// Transparency group Form XObject has its own /Resources dict, so it also needs /DefaultCMYK.
let mut forms = 0usize;
for name in page_res.x_objects.keys() {
if let Some(xo) = page_res.get_x_object(&name)
&& xo.dict().get::<Name>(b"Subtype").as_deref() == Some(b"Form")
{
forms += 1;
let form_res = craft_pdf::syntax::page::Resources::from_scope(xo.dict().get::<Dict>(b"Resources"), page_res.clone());
let (form_icc_id, form_n, _) = default_cmyk_profile(&form_res).expect("form /DefaultCMYK in plain export");
assert_eq!(form_n, 4);
assert_eq!(form_icc_id, icc_id, "all scopes share one embedded ICC stream");
}
}
assert!(forms > 0, "transparent box must produce a Form XObject");
// 2. PDF/X-4: passes check_pdfx4, keeps OutputIntents, and reuses DestOutputProfile for DefaultCMYK.
let x4_report =
crate::export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: crate::Standard::PdfX4, ..PdfOptions::default() }).unwrap();
assert!(x4_report.warnings.is_empty(), "{:?}", x4_report.warnings);
assert!(crate::check_pdfx4(&x4_report.bytes).is_empty());
let x4_pdf = Pdf::new(x4_report.bytes.clone()).expect("parse x4");
let catalog = x4_pdf.xref().get::<Dict>(x4_pdf.xref().root_id()).unwrap();
let intents = catalog.get::<Array>(b"OutputIntents").unwrap();
let oi = intents.iter::<Dict>().next().unwrap();
let dest_ref = oi.get_ref(b"DestOutputProfile").expect("DestOutputProfile ref");
let (x4_icc_id, x4_n, x4_prof) = default_cmyk_profile(x4_pdf.pages()[0].resources()).expect("page /DefaultCMYK in PDF/X-4");
assert_eq!(x4_n, 4);
assert_eq!(x4_icc_id, dest_ref.obj_number, "PDF/X-4 DefaultCMYK must reuse DestOutputProfile");
assert_eq!(x4_prof, expected);
// 3. Booklet export also includes /DefaultCMYK.
let booklet = crate::export_booklet(&d, &Cache::new(), &crate::BookletOptions::default()).unwrap();
let booklet_pdf = Pdf::new(booklet.bytes).expect("parse booklet");
let (_, b_n, b_prof) = default_cmyk_profile(booklet_pdf.pages()[0].resources()).expect("booklet /DefaultCMYK");
assert_eq!(b_n, 4);
assert_eq!(b_prof, expected);
// 4. PDF/X-1a and PDF/A-2b must NOT carry /DefaultCMYK (ICCBased is forbidden in PDF/X-1a; PDF/A-2b is RGB).
let x1a = crate::export_pdf(
&d,
&Cache::new(),
&PdfOptions { standard: crate::Standard::PdfX1a, created: Some(1_700_000_000), ..PdfOptions::default() },
)
.unwrap();
assert!(crate::check_pdfx1a(&x1a).is_empty());
let x1a_pdf = Pdf::new(x1a).expect("parse x1a");
assert!(default_cmyk_profile(x1a_pdf.pages()[0].resources()).is_none(), "PDF/X-1a cannot carry ICCBased DefaultCMYK");
let pdfa = crate::export_pdf(
&d,
&Cache::new(),
&PdfOptions { standard: crate::Standard::PdfA2b, created: Some(1_700_000_000), ..PdfOptions::default() },
)
.unwrap();
let pdfa_pdf = Pdf::new(pdfa).expect("parse pdfa");
assert!(default_cmyk_profile(pdfa_pdf.pages()[0].resources()).is_none());
}
/// Draw fills via hayro-interpret on page 0 and return each solid fill's resolved `[R, G, B, A]`.
fn interpreted_fill_rgba(bytes: &[u8]) -> Vec<[u8; 4]> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
struct Collector(Vec<[u8; 4]>);
impl Device<'_> for Collector {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) {
if matches!(mode, PathDrawMode::Fill(_))
&& let Paint::Color(c) = paint
{
self.0.push(c.to_rgba().to_rgba8());
}
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = craft_pdf::syntax::Pdf::new(bytes.to_vec()).unwrap();
let cache = InterpreterCache::new();
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 400.0, 400.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut out = Collector(Vec::new());
interpret_page(&pdf.pages()[0], &mut ctx, &mut out);
out.0
}
/// With `/DefaultCMYK`, hayro-interpret maps `DeviceCMYK(0.425, 0.175, 0.16, 0.071)` through the
/// Coated FOGRA39 ICC profile (`~[152, 179, 195]`) identically in plain export and PDF/X-4, instead
/// of falling back to naive untagged `1 - c` (`[136, 195, 199]`).
#[test]
fn default_cmyk_resolves_cmyk_colors_through_icc_profile() {
use designcraft_color::{Color, Swatch};
let mut d = Document::new(&NewDocument::default());
d.swatches.push(Swatch::color("Slate", Color::cmyk(0.425, 0.175, 0.16, 0.071)));
add_box(&mut d, Rect::new(20.0, 20.0, 120.0, 120.0), "Slate", |_| {});
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let x4 = export_pdf(&d, &Cache::new(), &PdfOptions { standard: crate::Standard::PdfX4, ..PdfOptions::default() }).unwrap();
let plain_fills = interpreted_fill_rgba(&plain);
let x4_fills = interpreted_fill_rgba(&x4);
assert_eq!(plain_fills.len(), 1);
assert_eq!(plain_fills, x4_fills, "plain export and PDF/X-4 must resolve CMYK identically");
let [r, g, b, a] = plain_fills[0];
assert_eq!(a, 255);
assert!(
(148..=158).contains(&r) && (175..=185).contains(&g) && (190..=200).contains(&b),
"expected FOGRA39 slate ~[152,179,195], got {:?}",
[r, g, b]
);
}
/// RGB-blend documents skip `/DefaultCMYK` when they contain only RGB, and attach it when they draw CMYK.
#[test]
fn rgb_blend_document_attaches_default_cmyk_only_when_cmyk_is_present() {
use designcraft_color::{Color, Swatch};
let mut d = Document::new(&NewDocument::default());
d.settings.blend_space = BlendSpace::Rgb;
d.swatches.push(Swatch::color("PureRGB", Color::rgb(0.2, 0.5, 0.8)));
d.swatches.push(Swatch::color("Slate", Color::cmyk(0.425, 0.175, 0.16, 0.071)));
add_box(&mut d, Rect::new(10.0, 10.0, 100.0, 100.0), "PureRGB", |_| {});
let pure_rgb = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let pure_pdf = Pdf::new(pure_rgb).unwrap();
assert!(default_cmyk_profile(pure_pdf.pages()[0].resources()).is_none(), "pure RGB export should not embed a CMYK ICC profile");
add_box(&mut d, Rect::new(120.0, 10.0, 200.0, 100.0), "Slate", |_| {});
let mixed = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let mixed_pdf = Pdf::new(mixed).unwrap();
let (_, n, prof) = default_cmyk_profile(mixed_pdf.pages()[0].resources()).expect("RGB doc with CMYK graphic needs /DefaultCMYK");
assert_eq!(n, 4);
assert_eq!(prof, craft_cms::builtin::COATED_FOGRA39_ICC);
}
/// An RGB-blend document whose only graphic is a CMYK-backed spot swatch writes a `/Separation`
/// color space with a `/DeviceCMYK` alternate, so `/DefaultCMYK` must still be attached.
#[test]
fn rgb_blend_spot_only_document_with_cmyk_alternate_attaches_default_cmyk() {
use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{Color, Swatch};
let mut d = Document::new(&NewDocument::default());
d.settings.blend_space = BlendSpace::Rgb;
d.swatches.push(Swatch {
name: "Spot Gold".into(),
value: SwatchValue::Color { color: Color::cmyk(0.0, 0.3, 1.0, 0.0), color_type: ColorType::Spot },
locked: false,
named: true,
hidden: false,
});
add_box(&mut d, Rect::new(10.0, 10.0, 100.0, 100.0), "Spot Gold", |_| {});
let pdf_bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let pdf = Pdf::new(pdf_bytes).unwrap();
let (_, n, prof) = default_cmyk_profile(pdf.pages()[0].resources()).expect("RGB doc with CMYK-alternate spot color needs /DefaultCMYK");
assert_eq!(n, 4);
assert_eq!(prof, craft_cms::builtin::COATED_FOGRA39_ICC);
}
+1 -1
View File
@@ -327,7 +327,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 and each reference's number as superscript text, not IDML endnote elements (#2753).
- Editorial notes (`note.new/edit/delete/convertToText/convertFromText/split/list`, Notes panel with Split Note, Type › Notes › Convert to Note): 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 blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); 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 color settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens colored 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 (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent; plain print, PDF/X-4, booklet and CMYK-containing RGB exports attach the working CMYK profile as `/DefaultCMYK`); 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 color settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens colored 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.