diff --git a/ROADMAP.md b/ROADMAP.md index 488adbf..97bb107 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/color/src/cms/icc_out.rs b/crates/color/src/cms/icc_out.rs new file mode 100644 index 0000000..60b98f1 --- /dev/null +++ b/crates/color/src/cms/icc_out.rs @@ -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) -> 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 { + 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> = 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:?}"); + } + } + } +} diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 7e68b8b..2294d76 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -11,6 +11,7 @@ pub mod generic; pub mod icc; +pub mod icc_out; pub mod lab; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 64fabae..ae7b539 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 { 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 }) } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 2a3d86c..d0ff6df 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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. diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs new file mode 100644 index 0000000..2419fca --- /dev/null +++ b/crates/pdf/src/pdfx.rs @@ -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> { + 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, 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!("<>\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!( + "<>", + 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!("<>", 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 = "PDF/X-4"; + let xml = xml.replacen("", &format!("{add}"), 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<>\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 { + 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 +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 10363bb..2bd9e04 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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)); } diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index aab042a..e774074 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -26,16 +26,16 @@ fn trans_dict(t: &PageTransition) -> String { format!("/Trans<>", t.duration.clamp(0.0, 60.0)) } -fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { +pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from) } -fn rfind(hay: &[u8], needle: &[u8]) -> Option { +pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option { 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 { +pub(crate) fn int_after(s: &str, key: &str) -> Option { 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 { } /// Byte range of object `id`'s body (between `obj` and `endobj`). -fn object(pdf: &[u8], id: usize) -> Option> { +pub(crate) fn object(pdf: &[u8], id: usize) -> Option> { let head = format!("\n{id} 0 obj"); let start = find(pdf, head.as_bytes(), 0)? + head.len(); let end = find(pdf, b"endobj", start)?;