Files
craft-libs/crates/prepress/README.md
aiongg 24a472717a prepress: transparency flattener (flatten feature) (#194)
* hayro-interpret: ink colors, overprint and font bytes for the flattener

Local patches for craft-prepress's PDF-in adapter, labeled Craft patch
(flatten) and listed in vendor/README.craft-reader.md:

- Device::set_overprint and Device::set_isolated, called where the
  blend mode is set and after an isolated group is pushed.
- Color::ink_paint, ColorSpace::ink_space and ink_values: colors and
  image samples as the inks they print, without converting to RGB.
  RasterImage::with_inks gives native samples with Decode and Matte.
- OutlineFontData::embedded and index, and the CFF bytes of Type1C
  fonts, so text can be embedded again.
- The sh operator ignores /Background (ISO 32000-2 8.7.4.5.1).

* plate-test: flattened-page checks, image alpha and scenes F01, F02

- report::run_flattened separates each scene's page after a caller's
  flattener as well as the original, and structure::transparency lists
  what transparency a page still has. plate-test doesn't depend on the
  flattener.
- Scene images take an alpha channel, written as /SMask.
- F01: a partly transparent image over a flat fill; where its alpha is
  0 the fill keeps its exact inks (vectorcraft#2160).
- F02: art hidden under opaque fills leaves no trace after flattening
  (vectorcraft#2161).

* prepress: transparency flattener (flatten feature)

craft_prepress::flatten turns PDF pages with transparency into pages
without it, keeping inks, overprint and live text, for PDF/X-1a and
other output that can't carry transparency.

- pdf: reads a page into a display list in inks through hayro-interpret:
  paths, outlined strokes, clips, groups, soft masks, overprint, spot
  and DeviceN colors, images in their own color space, gradients,
  rasterized mesh shadings, tiling patterns and glyph runs.
- font: prepares the source fonts for embedding again (TrueType,
  OpenType, CFF wrapped as OpenType); others fall back to outlines with
  ActualText.
- geom: planar regions with the shapes that cover each one (linesweeper).
- compose: composites each region per ink (separable and non-separable
  blend modes, isolated and knockout groups, overprint per plate as ISO
  32000 defines it); regions under images, gradients and soft masks are
  marked complex.
- raster: complex regions as DeviceN images through plate mode, grid
  snapped with a one-pixel outset; flatten_page.
- write: writes the result with the vendored krilla; flatten_document
  writes every page into one PDF with the output intent, page boxes and
  fonts shared across pages when their bytes match.

The regions and the compositor are ported from VectorCraft (commit
c2b15982, crates/pathops and crates/engine/src/cmd/flatten.rs), credited
in the crate README. scripts/plate-check.sh --flatten separates every
scene's flattened page with Ghostscript (local only). CI runs
craft-prepress with all features.

* krilla: DeviceN /Colorants from PDF 1.3, NChannel attributes from 1.6 (patch 11)

* prepress: flatten_document output PDF version and PDF/X version

* prepress: format the PDF/X-1a flatten test

* krilla, craft-prepress: write GTS_PDFXConformance next to GTS_PDFXVersion

PDF/X-1a:2003 files carry GTS_PDFXVersion (PDF/X-1:2003) and
GTS_PDFXConformance (PDF/X-1a:2003). Krilla patch 12 adds
SerializeSettings::pdfx_conformance, and FlattenDocumentOptions passes it
through.

* krilla, craft-pdf, craft-prepress: carry the modification date over

Krilla patch 13 adds Metadata::modification_date, written as /ModDate and
xmp:ModifyDate in place of the creation date. craft-pdf's Metadata gains
modified, and flatten_document takes it from the source's ModDate.

* craft-prepress: count the print annotations a read leaves out

ReadReport::dropped_annotations counts the annotations a printer would
paint (Print flag, not Hidden, with a normal appearance) when
ReadOptions::annotations is false; FlattenTotals sums it.

* craft-prepress: degenerate groups rasterize without a soft mask

A group the region sweep fails on is clipped to its members' boxes, which
no other group's items reach, so the paper is its only backdrop. Its
image is written opaque, as the plates of the items over paper, and the
page keeps no transparency for PDF/X-1a.

* craft-prepress: write untouched JPEG images as they are

A DCT-encoded DeviceGray or DeviceCMYK image that krilla's JPEG
passthrough writes with the same meaning (8 bits, no decode parameters,
the default gray Decode or the inverted Adobe CMYK one) keeps its data
in ImageItem::jpeg, and the writer embeds it unchanged when the image
has no soft mask. Other images are still 8-bit Flate samples.

* craft-prepress: keep group members with no transparency above them

Groups are transitive by bounding box, so a background frame put all the
body text on it under a transparent object far away, and the text came
back as outlined fills. A member is now drawn as it is when it is opaque
and, in every region its shapes are in, only members kept this way are
above it. The region fills under it are the composite of the other
members, and it is drawn over them in paint order.

* prepress: keep the backdrop under elements of a knockout group

* prepress: rasterize complex regions thinner than a point

* prepress: mark flatten errors, reasons and reports non_exhaustive

* prepress: bound the items a tiling pattern fill expands into

Each of up to MAX_TILING_CELLS cells copied the whole cell content, and a
cell can hold many items or an expanded pattern of its own. A fill whose
cells would hold more than MAX_TILING_ITEMS items in all, counting groups,
masks and nested patterns, is read as an image.

* prepress: stroke solid past the dash budget

A dash pattern that cuts a stroke into more than 250,000 pieces is outlined
solid, with the estimate hayro's renderer uses.

* prepress: bound the sampling and memory of rasterized tiling patterns

Each pixel sampled every cell over it, which for a BBox far larger than
its step is millions of cells per pixel; past MAX_TILING_CELLS cells per
pixel the fill paints nothing, and below it the image resolution drops to
keep the samples within MAX_TILING_SAMPLES. The canvases also hold a value
per page ink, so their pixel limits shrink with the ink count to keep the
memory of CMYK canvases, and the canvas size uses checked arithmetic.

* prepress: mark the page reader's error and report non_exhaustive

* prepress: format the tiling tests

* prepress: CodeRabbit fixes: no nonzero overprint mode for images and shadings, count dropped tilings, widen the hhea ascender

* prepress: refuse DeviceN spaces past the output's colorant limit and regions past the separated spot inks
2026-10-24 16:15:41 -04:00

225 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# craft-prepress
Prepress for the Craft apps: separations, ink limits and the ink manager.
- `ink`: the ink model the writers and previews share. `InkSet` holds the four process inks
(always first, in plate order) and the spot inks of a job, and is the job's ink manager
(aliases, spots to process); `InkColor` is a color as ink tints (`Separation`, `DeviceN`,
registration or none); `Overprint` holds `op`, `OP` and `OPM`. Spot names are compared as
PDF names: `/Gold#20Foil` and `Gold Foil` are one ink. `to_pdf_color` writes an `InkColor`
as a krilla color for export.
- `plates`: one 8-bit plane per ink (255 = 100% ink) rendered from a PDF page.
- `coverage`: ink coverage at a pixel and over a page, total area coverage (TAC) per pixel,
and the pixels and regions over a TAC limit.
- `composite`: an sRGB preview of chosen plates.
- `cms`: process color conversion into a CMYK printing condition through craft-cms.
- `flatten` (feature `flatten`): the display list a transparency flattener reads and writes:
fills, text runs, images and transparency groups in page space, with ink colors and overprint
per item. Text runs keep their font (bytes, face index, a dedup key), glyph ids and positions,
and Unicode text, so text without transparency stays live text; outlines are kept as the
fallback for fonts that can't be embedded again. `flatten` turns the transparent parts into
opaque region fills, compositing one value per ink (process and spot) with blend modes, group
opacity, isolated and knockout groups, and overprint per plate. Items without transparency
come back unchanged (JPEG images in DeviceGray or DeviceCMYK keep their DCT data; other images
are written as 8-bit Flate samples); regions a gradient, image or soft mask reaches are listed
for rasterizing. `flatten::raster` rasterizes them: each region's items are written alone,
separated with `plates` and written back as a DeviceN image of the page's inks, clipped to the
region, snapped to the device grid and a pixel larger than the clip so no paper shows at its
edges. `flatten_document` flattens every page of a PDF, or a range of pages, into one PDF with
the source's output intent, page boxes, title, author, creation date and modification date; a
font used on several pages is embedded once. The caller sets the PDF version and the PDF/X
version and conformance keys (for PDF/X-1a:2003, 1.4, `PDF/X-1:2003` and `PDF/X-1a:2003`);
krilla has no PDF/X validator, so the app keeps its own PDF/X checks. `flatten_page` is its
one-page case.
## Plates from an exported PDF
An app exports its PDF as usual, then separates a page of it:
```rust
use craft_prepress::{plates, InkId, Options, Process};
let p = plates(&pdf, 0, 150.0, &Options::default())?;
for (id, ink) in p.inks.iter() {
let plane = p.plane(id); // p.width × p.height tints, row-major
println!("{}: {} pixels inked", ink.name(), plane.map_or(0, |t| t.iter().filter(|v| **v > 0).count()));
}
let black = p.tint(InkId::process(Process::Black), 10, 10);
let gold = p.inks.lookup("Gold").and_then(|id| p.tint(id, 10, 10));
```
The plates are the PDF as printed, so a preview built on them shows what the writer produced,
not what the document model intended.
How a plate is made: the page renders once per ink through craft-pdf's `raster` with
hayro-interpret's plate mode (`InterpreterSettings::plate`). Every paint draws its tint of
that ink as gray film. A paint that overprints (`op`/`OP`, and `OPM 1` for zero DeviceCMYK
components) without naming the ink leaves the film as it is; with `Options::overprint` off,
every paint knocks out. Clipping, anti-aliasing, soft masks, transparency and knockout groups
stay the renderer's own. `Separation` and `DeviceN` colorants paint their own plates (`All`
every plate, `None` none), DeviceCMYK its components, DeviceGray black only.
Inks are found by a low-resolution pass that records each spot ink a paint uses, in the order
first painted. Spot inks used only inside soft masks are left out. Each spot gets a Lab
alternate measured from its sRGB appearance at 100%, so out-of-sRGB spots are clipped.
### Process color
RGB, CIE-based and ICCBased colors become CMYK through, in order:
1. the CMYK profile of the page's or the document's output intent (a `GTS_PDFX` intent first),
when craft-cms can convert into it;
2. `Options::fallback`: craft-cms's Coated FOGRA39 profile (the default), a profile the caller
gives, or the ISO 32000-2 §10.4.2.4 device formulas.
`Plates::conversion` says which was used. Conversions are relative colorimetric without black
point compensation. DeviceCMYK, and ICCBased CMYK in the printing condition's own profile,
separate as they are.
### Caps
| Constant | Value | Why |
|---|---|---|
| `MAX_SPOT_INKS` | 32 spot inks per page | A `DeviceN` space has at most 32 colorants, and each plate costs a render and a byte per pixel. Further spots are listed in `Plates::unseparated`. |
| `MAX_PLATE_PIXELS` | 4 MP per plate (2000 × 2000) | A page with many inks stays within a few hundred megabytes. The resolution is lowered to fit; `Plates::dpi` reports the one used. |
The renderer's side limit (`craft_pdf::raster::MAX_SIDE`) also lowers the resolution.
### Approximations
- Non-separable blend modes and groups that blend in RGB are approximated per ink.
- The graphics state's rendering intent is not read; transfer functions and halftones are not
applied.
## Coverage and TAC
Every reading takes `enabled`, indexed by `InkId::index` like a Separations Preview's
toggles: inks past its end count, so `&[]` counts every ink. Values are percent of one ink at
100%, so TAC runs to 100 × the number of inks.
| Call | Gives |
|---|---|
| `coverage_at(x, y, enabled)` | `Coverage`: each ink's tint and the TAC of the enabled inks at a pixel |
| `pixel_at(u, v)` | The pixel under a point given as fractions of the page, for a pointer readout |
| `tac(enabled)` | TAC per pixel, row-major |
| `stats(enabled)` | `Stats`: per ink `InkStats { max, mean, area }`; the page's highest TAC, where, and its mean |
| `above(limit, enabled)` | `Above`: a mask of the pixels over `limit` and their `count`; `regions()` lists connected regions with bounding boxes, largest first (at most `MAX_REGIONS`); `paint(rgb, color)` marks them on a preview |
## Composite preview
`Plates::composite(enabled, &cms)` returns sRGB8 (three bytes per pixel, row-major) of the
enabled inks:
- the process plates convert through `cms` (a `CmsConversion` for the plates' printing
condition, `Plates::conversion` names it), relative colorimetric with black point
compensation, or through the bundled CMYK profile when `cms` can't convert;
- each enabled spot plate multiplies over them by the spot's appearance at its tint: a Lab
alternate interpolated from paper white to the solid, or a CMYK alternate scaled by the tint.
Overprint is already in the plates (the renderer decides per plate what overprints and what
knocks out), so the preview shows it without rewriting any paint as a blend mode.
```rust
use craft_prepress::{CmsConversion, InkId};
let cms = CmsConversion::fogra39();
let mut enabled = vec![true; p.inks.len()];
enabled[InkId::process(craft_prepress::Process::Black).index()] = false; // hide Black
let mut rgb = p.composite(&enabled, &cms);
let over = p.above(300.0, &enabled);
over.paint(&mut rgb, [255, 0, 255]); // TAC warning
let worst = over.regions().first().copied();
```
## Ink manager
`InkSet` carries the job's ink settings next to its inks:
| Call | Effect |
|---|---|
| `alias(from, to)` | Spot `from` prints on `to`'s plate (a process ink or another spot, chains followed); `alias(from, from)` removes it. Loops and process `from` are refused. |
| `set_to_process(ink, on)` | Spot `ink` prints as process color, from its alternate (`spot_cmyk`). |
| `set_all_to_process(on)` | All Spots to Process; aliases still apply first. |
| `plate_of(ink)` | The plate a tint of `ink` lands on, `None` when it prints as process. |
| `resolve(&color, &cms)` | `color` with both applied. Tints landing on one plate combine as `1 − (1 − a)(1 − b)`; a converted color lists the four process inks first, and `InkColor::as_cmyk` gives the CMYK when no spot is left. |
`resolve` takes a `CmykConversion` (such as `CmsConversion`) because a spot with a Lab
alternate needs the printing condition to become CMYK.
Apply it in two places:
- **Export.** The writer calls `resolve` on every color it emits, then
`to_pdf_color(&resolved, &inks, &cms)` for the krilla color to paint with (see
[Export](#export)). An overprinting color that became CMYK is written with `OPM 1`, so its
zero components leave the plates under it as they are.
- **Preview.** The preview separates that exported PDF with `plates` and shows
`composite` and the coverage readings. Plates are the truth: an ink manager change shows
after the next export, and the preview never recomputes overprint from the document.
## Export
`to_pdf_color(&color, &inks, &cms)` gives the krilla color (`craft_pdf::write::backend::color::Color`)
for an `InkColor`, choosing the color space from the inks it names:
| Color | Written as |
|---|---|
| Tints of process inks only | DeviceCMYK; unlisted process inks are 0 |
| One spot ink | `Separation` with the spot's alternate as DeviceCMYK fallback |
| Any other mix | `DeviceN`: process inks first in plate order, then spots in `InkSet` order |
| `All` | `Separation /All` (registration) |
| `None` | `Separation /None` (marks no plate) |
A spot's alternate is `spot_cmyk`: a CMYK alternate as given, a Lab one converted through
`cms` into the printing condition, so a spot has one alternate in every space that names it.
`None` when the color names an ink not in the set or a `DeviceN` space would need more than
32 colorants.
```rust
use craft_prepress::{CmsConversion, InkColor, to_pdf_color};
let cms = CmsConversion::fogra39();
let printed = inks.resolve(&InkColor::Tints(vec![(gold, 1.0), (black, 0.5)]), &cms);
if let Some(color) = to_pdf_color(&printed, &inks, &cms) {
surface.set_fill(Some(Fill { paint: color.into(), ..Default::default() })); // DeviceN [Black Gold]
}
```
## In DesignCraft and VectorCraft
- **Separations Preview**: export the spread to PDF
in memory, `plates(pdf, 0, dpi, &Options::default())` at the zoom's resolution, then
`composite(enabled, &cms)` for the canvas. The panel lists `p.inks` with a toggle per row
feeding `enabled`, each row's percentage from `coverage_at` under the pointer
(`pixel_at`), and the TAC mode from `above(limit, enabled)` with `paint`. Single-plate view
shows `p.plane(id)` as gray film. It would replace VectorCraft's `proof.rs`, which
recolors the document and draws overprint as Darken.
- **Ink Manager** (DesignCraft's `InkManager` aliases and convert-to-process, VectorCraft's
`inks.rs` plates): keep one `InkSet` per document, add each spot swatch with its Lab or
CMYK alternate, and map the dialog's rows onto `alias`, `set_to_process` and
`set_all_to_process`. The PDF writer resolves swatch colors through it; the Separations
Preview then lists the plates the export produced.
- **Preflight**: `stats(&[])` gives per-ink max and mean coverage and the page's highest TAC
and its position for a report line such as "TAC 340% at …, limit 300%".
## Credits
- Plate mode, ink discovery, the output-intent handling and the coverage readings and
composite (`Separations::above`, `stats`, `composite`) come from
[storytold/pdfcraft#2770](https://github.com/storytold/pdfcraft/pull/2770) by Guillem Hermida
(guillehr2), with craft-cms in place of moxcms.
- Knockout groups in the renderer come from
[storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410.
- The flattener's planar regions (`flatten::geom`) are ported from VectorCraft at commit
`c2b15982` by the VectorCraft contributors: `crates/pathops/src/pathfinder.rs`,
`crates/pathops/src/boolean.rs` and `crates/engine/src/cmd/flatten.rs`.
- The flattener's compositor (`flatten::compose`) is ported from VectorCraft at commit
`c2b15982` by the VectorCraft contributors: `Composer`, `region_fill` and the raster/vector
balance in `crates/engine/src/cmd/flatten.rs`, and the blend formulas in
`crates/color/src/blend.rs`.
- The flattener's raster step (`flatten::raster`) follows VectorCraft's `render_raster` in
`crates/engine/src/cmd/flatten.rs` at commit `c2b15982`: the image reaching a pixel past its
area, and the coverage of the art drawn opaque as the image's alpha.
All MIT OR Apache-2.0, as this crate.