Print color: export overprint as /op, /OP and /OPM

Overprint Fill, Overprint Stroke, Overprint Gap, overprinting text (ruby,
kenten) and 100% [Black] under Appearance of Black are pushed through
Surface::push_overprint with OPM 1, around the paint they apply to.
PDF/A exports are RGB and leave overprint out with a warning.
This commit is contained in:
aiongg
2026-10-20 13:12:15 +00:00
parent 6a04039f5f
commit 2b6ed2ccf0
7 changed files with 187 additions and 34 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML; PDF export writes them as `/op` and `/OP` with `/OPM 1`.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
@@ -68,7 +68,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- 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.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack; 100% [Black] fills, strokes and text overprint in PDF export).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
+65 -11
View File
@@ -630,6 +630,27 @@ impl Exporter<'_> {
self.doc.settings.intent == designcraft_doc::Intent::Print || self.opts.standard == Standard::PdfX4
}
/// Whether a paint overprints: its own attribute, or 100% [Black] when Preferences ›
/// Appearance of Black overprints it (not in PDF/A, whose RGB output has no overprint).
pub(crate) fn overprints(&self, swatch: &str, tint: f32, flag: bool) -> bool {
flag || (!self.rgb_only && self.doc.settings.overprint_black && swatch == designcraft_color::swatch::BLACK && tint >= 0.999)
}
/// Overprint for what is painted until the matching pop: `/op` for fills, `/OP` for strokes,
/// and `/OPM 1`, so a CMYK paint's zero components leave the inks beneath it alone. Returns
/// whether it pushed. PDF/A exports are RGB and leave overprint out.
pub(crate) fn push_overprint(&mut self, s: &mut Surface, fill: bool, stroke: bool) -> bool {
if !fill && !stroke {
return false;
}
if self.rgb_only {
self.warn("PDF/A: overprint was left out");
return false;
}
s.push_overprint(krilla::blend::Overprint { fill, stroke, nonzero: true });
true
}
pub(crate) fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
@@ -899,10 +920,7 @@ impl Exporter<'_> {
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
self.fill_item_path(s, &it.fill, paint, p);
}
s.pop();
// The clip in page space, so the children keep their absolute transforms.
@@ -968,13 +986,7 @@ impl Exporter<'_> {
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
if it.fill.overprint {
self.warn("overprint is not exported yet");
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
self.fill_item_path(s, &it.fill, paint, p);
}
if frame_art {
s.end_tagged();
@@ -998,9 +1010,14 @@ impl Exporter<'_> {
// A gradient spans the path's box.
let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() };
if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) {
let op = self.overprints(&st.fill, st.fill_tint, st.overprint_fill);
let op = self.push_overprint(s, op, false);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&p);
s.set_fill(None);
if op {
s.pop();
}
}
}
} else if let Some(ft) = cs.frame(it.id) {
@@ -1064,6 +1081,19 @@ impl Exporter<'_> {
}
}
/// Fill `p` with an item's fill paint, overprinting when the fill does.
fn fill_item_path(&mut self, s: &mut Surface, fill: &designcraft_doc::Fill, paint: krilla::paint::Paint, p: &Path) {
let op = self.overprints(&fill.swatch, fill.tint, fill.overprint);
let pushed = self.push_overprint(s, op, false);
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
if pushed {
s.pop();
}
}
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
self.fill_paint_in(fill, bounds, Affine::IDENTITY)
@@ -1164,6 +1194,10 @@ impl Exporter<'_> {
c.into()
};
let closed = it.path.is_closed();
// Overprint Stroke covers the stroke and its arrowheads, Overprint Gap the gap colour;
// each is pushed around what it paints (a filled outline or a stroked path).
let stroke_op = self.overprints(&st.swatch, st.tint, st.overprint);
let gap_op = self.overprints(&st.gap_swatch, st.gap_tint, st.gap_overprint);
let mut cap = match st.cap {
designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,
designcraft_doc::Cap::Round => krilla::paint::LineCap::Round,
@@ -1178,14 +1212,22 @@ impl Exporter<'_> {
if let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
&& st.gap_swatch != designcraft_color::swatch::NONE
{
let pushed = self.push_overprint(s, gap_op, gap_op);
s.set_stroke(Some(Stroke { paint: g.into(), width: st.weight as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.set_fill(None);
s.draw_path(path);
s.set_stroke(None);
if pushed {
s.pop();
}
}
let pushed = self.push_overprint(s, stroke_op, stroke_op);
s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&op);
s.set_fill(None);
if pushed {
s.pop();
}
return;
}
// Viewers expand dashes too: a degenerate pattern draws solid, as on screen.
@@ -1233,9 +1275,14 @@ impl Exporter<'_> {
&& let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
{
// Gap colour under dashes and dots.
let pushed = self.push_overprint(s, gap_op, gap_op);
s.set_stroke(Some(Stroke { paint: g.into(), width: width as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.draw_path(path);
if pushed {
s.pop();
}
}
let pushed = self.push_overprint(s, stroke_op, stroke_op);
s.set_stroke(Some(Stroke {
paint: paint.clone(),
width: width as f32,
@@ -1251,9 +1298,13 @@ impl Exporter<'_> {
}));
s.draw_path(path);
s.set_stroke(None);
if pushed {
s.pop();
}
for _ in 0..pushes {
s.pop();
}
let pushed = self.push_overprint(s, stroke_op, stroke_op);
for h in arrows.iter().flat_map(|a| &a.1) {
let Some(p) = to_path(&h.path) else { continue };
match h.outline {
@@ -1269,6 +1320,9 @@ impl Exporter<'_> {
}
}
}
if pushed {
s.pop();
}
}
/// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the
+3 -1
View File
@@ -14,6 +14,8 @@
//! as `/Separation /All`.
//! Gradients with mixed output colour spaces are converted to RGB with an export warning;
//! homogeneous gradients keep their existing colour space (subject to PDF/A conversion).
//! - **Overprint:** Overprint Fill, Stroke and Gap, overprinting text and 100% [Black] (when
//! Appearance of Black says so) as `/op` and `/OP` with `/OPM 1`; left out of PDF/A.
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]). PDF/X-4 and PDF/A
//! images are written without interpolation. In PDF/X-4 an RGB image without a profile of its own
@@ -25,7 +27,7 @@
//! - **Standards:** PDF/A-2b through krilla's validator and PDF/X-4 output intent/identification
//! with built-in conformance checks. `designcraft-cli validate-pdf` exposes those checks for CI.
//!
//! Not yet: tagged PDF, bookmarks, hyperlinks, overprint, layers as optional content.
//! Not yet: tagged PDF, bookmarks, hyperlinks, layers as optional content.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
+109
View File
@@ -131,3 +131,112 @@ fn output_intent_is_the_cmyk_working_space() {
assert_eq!(intent.profile, &*want.icc);
assert_ne!(intent.profile, craft_cms::builtin::COATED_FOGRA39_ICC);
}
/// The overprint entries of every ExtGState that has them: (`/op`, `/OP`, `/OPM`).
fn overprint_states(bytes: &[u8]) -> Vec<(bool, bool, i32)> {
let pdf = Pdf::new(bytes.to_vec()).unwrap();
pdf.objects()
.into_iter()
.filter_map(|o| match o {
Object::Dict(d) => Some(d),
_ => None,
})
.filter_map(|d| Some((d.get::<bool>(b"op")?, d.get::<bool>(b"OP")?, d.get::<i32>(b"OPM")?)))
.collect()
}
/// A document whose [Black] doesn't overprint by itself, with one box `edit` sets up.
fn one_box(swatch: &str, edit: impl FnOnce(&mut Item)) -> Document {
let mut d = Document::new(&NewDocument::default());
d.settings.overprint_black = false;
add_box(&mut d, Rect::new(36.0, 36.0, 236.0, 236.0), swatch, edit);
d
}
fn exported(d: &Document) -> Vec<u8> {
export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap()
}
/// Overprint Fill, Overprint Stroke and Overprint Gap are written as `/op` and `/OP` with
/// `/OPM 1`; without them nothing overprints.
#[test]
fn overprint_attributes_are_written() {
const CYAN: &str = "C=100 M=0 Y=0 K=0";
let stroked = |b: &mut Item| {
b.stroke.swatch = "C=0 M=100 Y=0 K=0".into();
b.stroke.weight = 4.0;
};
assert_eq!(overprint_states(&exported(&one_box(CYAN, stroked))), []);
let fill = exported(&one_box(CYAN, |b| b.fill.overprint = true));
assert_eq!(overprint_states(&fill), [(true, false, 1)]);
let stroke = exported(&one_box(CYAN, |b| {
stroked(b);
b.stroke.overprint = true;
}));
assert!(overprint_states(&stroke).iter().all(|(_, s, opm)| *s && *opm == 1), "{:?}", overprint_states(&stroke));
assert!(!overprint_states(&stroke).is_empty());
let gap = exported(&one_box(CYAN, |b| {
stroked(b);
b.stroke.kind = designcraft_doc::StrokeType::Dashed { pattern: vec![12.0, 6.0] };
b.stroke.gap_swatch = "C=0 M=0 Y=100 K=0".into();
b.stroke.gap_overprint = true;
}));
assert!(overprint_states(&gap).iter().all(|(_, s, opm)| *s && *opm == 1), "{:?}", overprint_states(&gap));
assert_eq!(overprint_states(&gap).len(), 1, "only the gap overprints");
// PDF/A exports are RGB: overprint is left out, with a warning.
let opts = PdfOptions { standard: crate::Standard::PdfA2b, ..PdfOptions::default() };
let r = crate::export_pdf_with_report(&one_box(CYAN, |b| b.fill.overprint = true), &Cache::new(), &opts).unwrap();
assert_eq!(overprint_states(&r.bytes), []);
assert!(r.warnings.iter().any(|w| w == "PDF/A: overprint was left out"), "{:?}", r.warnings);
}
/// 100% [Black] fills, strokes and text overprint when Appearance of Black says so; tints of
/// it and other colours don't.
#[test]
fn black_overprints_with_the_preference() {
let black = designcraft_color::swatch::BLACK;
let mut on = one_box(black, |_| {});
on.settings.overprint_black = true;
assert_eq!(overprint_states(&exported(&on)), [(true, false, 1)]);
let mut tint = one_box(black, |b| b.fill.tint = 0.5);
tint.settings.overprint_black = true;
assert_eq!(overprint_states(&exported(&tint)), []);
assert_eq!(overprint_states(&exported(&one_box(black, |_| {}))), [], "off in the preferences");
let mut stroke = one_box(designcraft_color::swatch::NONE, |b| {
b.stroke.swatch = black.into();
b.stroke.weight = 2.0;
});
stroke.settings.overprint_black = true;
assert!(overprint_states(&exported(&stroke)).iter().any(|(_, s, opm)| *s && *opm == 1));
// Text in [Black], the default.
let mut text = Document::new(&NewDocument::default());
let lid = text.default_layer();
text.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Black text", Default::default()).unwrap();
assert_eq!(overprint_states(&exported(&text)), [(true, false, 1)]);
// PDF/A leaves [Black] overprint out without a warning: it is the default for all black text.
let opts = PdfOptions { standard: crate::Standard::PdfA2b, ..PdfOptions::default() };
let r = crate::export_pdf_with_report(&text, &Cache::new(), &opts).unwrap();
assert_eq!(overprint_states(&r.bytes), []);
assert!(!r.warnings.iter().any(|w| w.contains("overprint")), "{:?}", r.warnings);
text.settings.overprint_black = false;
assert_eq!(overprint_states(&exported(&text)), []);
}
/// Ruby with Overprint Fill on overprints.
#[test]
fn ruby_overprint_fill_is_written() {
use designcraft_doc::cjk_settings::AdornmentOverprint;
let mut d = Document::new(&NewDocument::default());
d.settings.overprint_black = false;
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, "漢字です", Default::default()).unwrap();
assert_eq!(overprint_states(&exported(&d)), []);
let sid = *d.stories.keys().next().unwrap();
d.story_mut(sid).unwrap().format_chars(0.."漢字".len(), |f| {
f.over.ruby = Some("かんじ".into());
f.over.ruby_overprint_fill = Some(AdornmentOverprint::On);
});
let r = crate::export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(overprint_states(&r.bytes), [(true, false, 1)]);
assert!(!r.warnings.iter().any(|w| w.contains("overprint")), "{:?}", r.warnings);
}
-15
View File
@@ -592,21 +592,6 @@ fn column_rules_are_drawn() {
assert!(rules(false).is_empty());
}
#[test]
fn ruby_overprint_is_reported_as_not_exported() {
use designcraft_doc::cjk_settings::AdornmentOverprint;
let mut d = doc_with_text("漢字です");
let sid = *d.stories.keys().next().unwrap();
let clean = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(!clean.warnings.iter().any(|w| w.contains("overprint")), "{:?}", clean.warnings);
d.story_mut(sid).unwrap().format_chars(0.."漢字".len(), |f| {
f.over.ruby = Some("かんじ".into());
f.over.ruby_overprint_fill = Some(AdornmentOverprint::On);
});
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("overprint is not exported yet")), "{:?}", r.warnings);
}
#[test]
fn small_caps_and_small_cap_figures_extract_as_typed() {
// Source Sans 3 substitutes letters and figures under `smcp` / `c2sc` (`one` → `one.s`),
+6 -3
View File
@@ -483,9 +483,9 @@ impl Exporter<'_> {
if fill.is_none() && stroke.is_none() {
return;
}
if st.overprint_fill || st.overprint_stroke {
self.warn("overprint is not exported yet");
}
let fill_op = fill.is_some() && self.overprints(&st.fill, st.fill_tint, st.overprint_fill);
let stroke_op = stroke.is_some() && self.overprints(&st.stroke, st.stroke_tint, st.overprint_stroke);
let overprint = self.push_overprint(s, fill_op, stroke_op);
// Stroke behind fill: a double-width stroke first, as outlines so the text is not
// duplicated, then the fill over its inner half. With no fill, the stroke is clipped
// to the outside of the glyphs instead.
@@ -516,6 +516,9 @@ impl Exporter<'_> {
if clipped {
s.pop();
}
if overprint {
s.pop();
}
}
/// A run as real text.
+2 -2
View File
@@ -307,7 +307,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics.
- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come.
- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML.
- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML; PDF export writes them as `/op` and `/OP` with `/OPM 1`.
- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`.
- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign.
- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts.
@@ -323,7 +323,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- 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.
- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName.
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack; 100% [Black] fills, strokes and text overprint in PDF export).
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps.