O: PDF/X-4 — output intent with our own CMYK ICC profile, identification, checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:13:53 -04:00
parent 368767ee5d
commit e95f562425
8 changed files with 308 additions and 14 deletions

View File

@ -96,6 +96,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Kashida justification, caret movement in visual order and digit substitution aren't there yet.
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish and Japanese menu titles, common menu items and panel names (our own translations); dialogs and the rest of the UI stay English.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
@ -153,7 +154,7 @@ four agents in parallel on separate crates:
| Work | Estimate |
|---|---|
| Open P0 (3: PDF/X-4 output intent and validation, EPS place, remaining Preferences sections) | 12 h |
| Open P0 (3: full PDF/X-4 validation against a certified checker, EPS place, remaining Preferences sections) | 9 h |
| Open P1 (5: New Window, colour management, language support, UI keyboard navigation, …) | 15 h |
| Open P2 (37: 12 missing — spread rotation, page transitions, vertical/RTL type, math, video/audio, split window, localization, flattener, placed-file layers — and 25 partial) | 110 h |
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |

View File

@ -0,0 +1,123 @@
//! An ICC output profile of our Generic CMYK press model (for PDF/X output intents): lut16
//! tables sampled from the model — CMYK → Lab from the forward model, Lab → CMYK from its
//! inverse (separation with black generation and the ink limit). The profile is ours, built in
//! code; it describes our model, not a measured press.
use std::sync::OnceLock;
use moxcms::{
ColorProfile, DataColorSpace, LocalizableString, LutDataType, LutStore, LutType, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
RenderingIntent,
};
use super::Intent;
use super::lab::Lab;
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// ICC v2 legacy 16-bit Lab encoding (lut16Type).
fn enc_lab(l: Lab) -> [u16; 3] {
let q = |v: f32| v.round().clamp(0.0, 65535.0) as u16;
[q(l.l * 652.8), q((l.a + 128.0) * 256.0), q((l.b + 128.0) * 256.0)]
}
fn dec_lab(v: [u16; 3]) -> Lab {
Lab::new(v[0] as f32 / 652.8, v[1] as f32 / 256.0 - 128.0, v[2] as f32 / 256.0 - 128.0)
}
fn lut(inputs: u8, outputs: u8, grid: usize, clut: Vec<u16>) -> LutWarehouse {
let ident = |n: u8| LutStore::Store16((0..n).flat_map(|_| [0u16, 65535]).collect());
LutWarehouse::Lut(LutDataType {
num_input_channels: inputs,
num_output_channels: outputs,
num_clut_grid_points: grid as u8,
matrix: Matrix3d::IDENTITY,
num_input_table_entries: 2,
num_output_table_entries: 2,
input_table: ident(inputs),
clut_table: LutStore::Store16(clut),
output_table: ident(outputs),
lut_type: LutType::Lut16,
})
}
fn text(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
/// The Generic CMYK model as an ICC output profile (built once).
pub fn generic_cmyk_icc() -> &'static [u8] {
static P: OnceLock<Vec<u8>> = OnceLock::new();
P.get_or_init(|| {
let g = super::generic();
let step = |i: usize, n: usize| i as f32 / (n - 1) as f32;
// CMYK → Lab (media-relative), first input channel varying slowest.
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
for c in 0..A2B_GRID {
for m in 0..A2B_GRID {
for y in 0..A2B_GRID {
for k in 0..A2B_GRID {
let lab = g.to_lab_rel([step(c, A2B_GRID), step(m, A2B_GRID), step(y, A2B_GRID), step(k, A2B_GRID)]);
a2b.extend(enc_lab(lab));
}
}
}
}
// Lab → CMYK through the model's separation.
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
let e = |i: usize| (i as f32 * 65535.0 / (B2A_GRID - 1) as f32).round() as u16;
for l in 0..B2A_GRID {
for a in 0..B2A_GRID {
for b in 0..B2A_GRID {
let lab = dec_lab([e(l), e(a), e(b)]);
let cmyk = g.from_lab(lab, Intent::RelativeColorimetric, true);
b2a.extend(cmyk.map(|v| (v.clamp(0.0, 1.0) * 65535.0).round() as u16));
}
}
}
let a2b = lut(4, 3, A2B_GRID, a2b);
let b2a = lut(3, 4, B2A_GRID, b2a);
let mut p = ColorProfile::default();
p.pcs = DataColorSpace::Lab;
p.color_space = DataColorSpace::Cmyk;
p.profile_class = ProfileClass::OutputDevice;
p.rendering_intent = RenderingIntent::RelativeColorimetric;
let d50 = moxcms::Xyzd { x: 0.9642, y: 1.0, z: 0.8249 };
p.white_point = d50;
p.media_white_point = Some(d50);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b.clone());
p.lut_a_to_b_saturation = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a.clone());
p.lut_b_to_a_saturation = Some(b2a);
p.description = text(super::GENERIC_CMYK);
p.copyright = text("No copyright; made by DesignCraft from its parametric press model. Use freely.");
p.encode().unwrap_or_default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generic_cmyk_profile_round_trips() {
let bytes = generic_cmyk_icc();
assert!(bytes.len() > 1000);
assert_eq!(&bytes[36..40], b"acsp");
assert_eq!(&bytes[16..20], b"CMYK");
assert_eq!(&bytes[12..16], b"prtr");
// Read back: CMYK → sRGB through the profile is close to the model.
let p = super::super::icc::IccProfile::from_bytes(None, bytes).expect("parses");
let g = super::super::generic();
for cmyk in [[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0], [0.2, 0.3, 0.1, 0.5]] {
let via = p.to_srgb(&cmyk, Intent::RelativeColorimetric).expect("converts");
let model = g.to_srgb(cmyk, false);
for i in 0..3 {
assert!((via[i] - model[i]).abs() < 0.04, "{cmyk:?}: {via:?} vs {model:?}");
}
}
}
}

View File

@ -11,6 +11,7 @@
pub mod generic;
pub mod icc;
pub mod icc_out;
pub mod lab;
use std::sync::atomic::{AtomicBool, Ordering};

View File

@ -102,15 +102,10 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
/// 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> {
let sheets = sheets(doc, opts)?;
let mut warnings = Vec::new();
let warnings = Vec::new();
let (version, archival) = match opts.standard {
Standard::None => (PdfVersion::Pdf17, None),
Standard::PdfX4 => {
warnings.push(
"PDF/X-4: the output intent and PDF/X identification are not written yet; the file is PDF 1.6 with trim and bleed boxes".to_string(),
);
(PdfVersion::Pdf16, None)
}
Standard::PdfX4 => (PdfVersion::Pdf16, None),
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
};
let mut cb = ConfigurationBuilder::new().with_version(version);
@ -203,8 +198,18 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let mut warnings = ex.warnings;
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
}
for issue in crate::pdfx::check_pdfx4(&bytes) {
warnings.push(format!("PDF/X-4: {issue}"));
}
}
warnings.dedup();
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
}

View File

@ -30,8 +30,10 @@ mod links;
mod marks;
mod text;
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, make_pdfx4};
pub use transitions::add_transitions;
/// PDF standard to target.

154
crates/pdf/src/pdfx.rs Normal file
View File

@ -0,0 +1,154 @@
//! PDF/X-4: the output intent (our Generic CMYK 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.
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
}
/// Turn krilla's output into PDF/X-4. `None` 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) -> Option<Vec<u8>> {
let t_at = rfind(pdf, b"trailer")?;
let trailer = String::from_utf8_lossy(&pdf[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 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 icc = designcraft_color::cms::icc_out::generic_cmyk_icc();
let (icc_id, oi_id) = (size, size + 1);
let mut s = format!("<</N 4/Length {}>>\nstream\n", icc.len()).into_bytes();
s.extend_from_slice(icc);
s.extend_from_slice(b"\nendstream");
put(&mut out, icc_id, &s);
let cond = designcraft_color::cms::GENERIC_CMYK;
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("Custom"),
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.
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 add = "<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>";
let xml = xml.replacen("</rdf:RDF>", &format!("{add}</rdf:RDF>"), 1);
let dict = match dict.find("/Length") {
Some(i) => {
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..])
}
None => return None,
};
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 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());
Some(out)
}
/// PDF/X-4 checks on an exported file: what's missing (empty = passes these checks).
pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
let mut issues = Vec::new();
let text = String::from_utf8_lossy(pdf);
if !pdf.starts_with(b"%PDF-1.") || pdf.get(7).is_none_or(|v| *v > b'6') {
issues.push("the PDF version must be 1.6 or lower".into());
}
if !text.contains("/S/GTS_PDFX") {
issues.push("no PDF/X output intent".into());
}
if !text.contains("/DestOutputProfile") {
issues.push("the output intent has no destination profile".into());
}
if !text.contains("/GTS_PDFXVersion(PDF/X-4)") {
issues.push("the document information doesn't name PDF/X-4".into());
}
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
issues.push("the XMP metadata doesn't name PDF/X-4".into());
}
if text.contains("/Encrypt") {
issues.push("the file is encrypted".into());
}
let pages = text.matches("/Type/Page/").count() + text.matches("/Type/Page>").count();
let trims = text.matches("/TrimBox").count();
if pages > 0 && trims < pages {
issues.push("every page needs a trim box".into());
}
if text.contains("/Subtype/Type3") {
issues.push("Type 3 fonts aren't allowed".into());
}
issues
}

View File

@ -156,7 +156,15 @@ fn standards() {
assert!(String::from_utf8_lossy(&r.bytes).contains("pdfaid"));
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap();
assert!(r.bytes.starts_with(b"%PDF-1.6"));
assert!(!r.warnings.is_empty());
// 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();
assert!(text.contains("/OutputIntents[") && text.contains("DesignCraft Generic CMYK"));
// 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());
assert!(crate::check_pdfx4(&plain).len() >= 3);
assert_eq!(Standard::parse("PDF/X-4"), Some(Standard::PdfX4));
assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b));
}

View File

@ -26,16 +26,16 @@ fn trans_dict(t: &PageTransition) -> String {
format!("/Trans<</Type/Trans{body}/D {:.2}>>", t.duration.clamp(0.0, 60.0))
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
}
fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).rposition(|w| w == needle)
}
/// The integer after `key` in `s` (e.g. `/Size 14`, `/Root 13 0 R`).
fn int_after(s: &str, key: &str) -> Option<usize> {
pub(crate) fn int_after(s: &str, key: &str) -> Option<usize> {
let i = s.find(key)? + key.len();
let rest = s[i..].trim_start();
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
@ -43,7 +43,7 @@ fn int_after(s: &str, key: &str) -> Option<usize> {
}
/// Byte range of object `id`'s body (between `obj` and `endobj`).
fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
pub(crate) fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> {
let head = format!("\n{id} 0 obj");
let start = find(pdf, head.as_bytes(), 0)? + head.len();
let end = find(pdf, b"endobj", start)?;