SoftProof
a poster converted into a press profile and back before it is printed, so the colors the ink cannot reproduce show up changed on screen, next to the gamut check and the process plates
- Run
swift run --package-path Examples Example-Color-SoftProof- Source
- Examples/Color/SoftProof/Sketch.swift
import Ollin
/// Seeing the print before you print it: proofing a poster against a press.
///
/// A screen makes color with light and a press makes it with ink on paper, and
/// the screen wins. The vivid cyan of a backlit pixel is simply not a color
/// four inks can lay down, so it arrives at the press and comes back as the
/// nearest thing the ink can do. A soft proof runs that trip on purpose: the
/// canvas is carried into the printer's profile and back out again, and what
/// comes home changed is exactly what the press is going to change.
///
/// The `view` parameter shows the four ways to look at it. **Sweep** wipes the proof
/// across the poster so the two versions meet at a moving edge, which is the
/// quickest way to see what a press takes away. **Proof** is the whole canvas
/// under the printing condition. **Gamut** leaves the colors alone and flags
/// what will not survive, so a palette can be fixed before anything is
/// separated. **Plates** is what actually goes to the shop: one grayscale
/// plate per ink, black where that ink lands.
///
/// The proof itself runs on the GPU (`Filter.softProof`), so the intent and
/// paper parameters answer immediately. The plates are CPU work through the same
/// profile, done once and again whenever the intent changes. Write the print
/// files with:
///
/// --export-plates poster.png [--screen halftone]
enum ProofView: String, CaseIterable, ParamOption { case sweep, proof, gamut, plates }
@main
final class SoftProofSketch: Sketch {
/// The press this poster is made for. A shop hands out its own profile for
/// the actual press and paper; this generic four-ink one stands in, and is
/// close enough to show which colors are in trouble.
override var printProfile: ICCProfile? { .genericCMYK }
@Param(style: .segmented, icon: "printer") var view: ProofView = .sweep
@Param(icon: "arrow.triangle.merge") var intent: RenderingIntent = .relative
@Param(icon: "doc.plaintext") var showPaperColor = false
private let labelFont = OutlineFont.system
private var artwork = Image(width: 1, height: 1)
private var plates: ProcessSeparation?
private var platesIntent: RenderingIntent?
/// The printing condition the whole sketch reads from: press, canvas,
/// intent, and whether the paper's own color is shown.
private var proof: SoftProof {
var condition = SoftProof(printProfile ?? .genericCMYK, intent: intent)
condition.simulatesPaper = showPaperColor
return condition
}
override func setup() {
noStroke()
textFont(labelFont)
artwork = paint()
}
override func draw() {
background(Color(hex: 0x0E0E12))
switch view {
case .sweep: drawSweep()
case .proof: drawProofed(warning: nil, amount: 1)
case .gamut: drawProofed(warning: Color(white: 0.55), amount: 0)
case .plates: drawPlates()
}
}
/// The poster with the proof wiped across it: ink on the left of the edge,
/// light on the right. The edge travels, so the difference reads as
/// movement rather than as two pictures to compare from memory.
private func drawSweep() {
let layer = makeRenderTarget()
withTarget(layer) { drawImage(artwork, in: bounds) }
drawImage(artwork, in: bounds)
// The wipe starts closed and travels, so a frame exported at time zero
// is the poster itself: `--export-plates` then separates the artwork
// rather than a half already carried through the press.
let edge = (0.5 - 0.5 * cos(time * 0.45)) * width
let proofed = layer.filtered(.softProof(proof))
withClip(Rectangle(x: 0, y: 0, width: edge, height: height)) {
drawImage(proofed.image, in: bounds)
}
withState {
stroke(Color(white: 1, alpha: 0.7))
strokeWeight(2)
drawLine(edge, 0, edge, height)
}
label("printed", at: Vector2(edge - 18, 34), align: .right)
label("on screen", at: Vector2(edge + 18, 34), align: .left)
drawCaption("the same poster on both sides of the line")
}
/// The whole canvas under the printing condition. At `amount: 0` the colors
/// are left as drawn and only the warning shows, which is the mode for
/// checking a palette without living inside the proof.
private func drawProofed(warning: Color?, amount: Double) {
let layer = makeRenderTarget()
withTarget(layer) { drawImage(artwork, in: bounds) }
let proofed = layer.filtered(.softProof(proof, warning: warning, amount: amount))
drawImage(proofed.image, in: bounds)
if warning == nil {
let paper = showPaperColor ? ", on its own paper" : ""
drawCaption("\(proof.destination.name), \(intent.rawValue) intent\(paper)")
} else {
drawCaption("gray marks the colors this press cannot make")
}
}
/// What goes to the shop: one plate per ink, black where the ink lands.
private func drawPlates() {
ensurePlates()
guard let plates, !plates.plates.isEmpty else {
return drawStatus("this profile could not be read")
}
let tile = 440.0, gutter = 36.0
let origin = (width - tile * 2 - gutter) / 2
textSize(16)
textAlign(.left, .middle)
for (index, plate) in plates.plates.enumerated() {
let x = origin + Double(index % 2) * (tile + gutter)
let y = origin - 40 + Double(index / 2) * (tile + gutter + 40)
drawImage(plate.master, in: Rectangle(x: x, y: y, width: tile, height: tile))
fill(.white)
let coverage = Int((plate.averageInk * 100).rounded())
drawText("\(plate.name.lowercased()) \(coverage)% ink", x, y + tile + 21)
}
let peak = Int((plates.peakTotalInk * 100).rounded())
drawCaption("four plates, \(peak)% ink at the heaviest spot")
}
/// Separate the poster, once per intent. This is per-pixel CPU work through
/// the profile, so it runs when the answer would change and not before.
private func ensurePlates() {
guard plates == nil || platesIntent != intent else { return }
plates = artwork.separated(into: proof)
platesIntent = intent
}
private func label(_ text: String, at position: Vector2, align: HorizontalTextAlign) {
withState {
noStroke()
fill(Color(white: 1, alpha: 0.85))
textSize(18)
textAlign(align, .middle)
drawText(text, position.x, position.y)
}
}
/// A poster in the colors a screen is good at and a press is not: an
/// electric sea, a fluorescent hill, a sun on a hot gradient. The strip of
/// neutrals along the bottom is the control, since ink handles those
/// perfectly well and they should come through the proof untouched.
private func paint() -> Image {
let w = 1080, h = 1080
let image = Image(width: w, height: h)
let dusk = Color(hex: 0x2B1B6B)
let heat = Color(hex: 0xFF2D55)
let sun = Color(hex: 0xFFD400)
let sea = Color(hex: 0x00E5FF)
let hill = Color(hex: 0x00FF66)
for y in 0..<h {
let v = Double(y) / Double(h - 1)
for x in 0..<w {
let u = Double(x) / Double(w - 1)
var c = Color.mix(dusk, heat, smoothstep(0.05, 0.62, v))
let disk = dist(u, v, 0.62, 0.30)
c = Color.mix(c, sun, 1 - smoothstep(0.145, 0.152, disk))
c = Color.mix(c, sun, clamp(1 - disk * 2.6, 0, 1) * 0.35)
// The sea starts at a flat horizon and carries a few bands.
if v > 0.62 {
let bands = 0.5 + 0.5 * sin((v - 0.62) * 90 + sin(u * 6) * 1.4)
c = Color.mix(sea, dusk, bands * 0.35 * smoothstep(0.62, 0.95, v))
}
// A fluorescent headland cuts in from the left.
let ridge = 0.78 + 0.10 * sin(u * 3.4 + 2.1)
if v > ridge {
c = Color.mix(hill, dusk, smoothstep(ridge, 1.15, v) * 0.5)
}
// The control strip: neutrals a press reproduces exactly. The
// margin under it is the poster's own, and keeps the caption
// off the light end of the ramp.
if v > 0.86 { c = Color(white: 0.12 + (u * 8).rounded(.down) / 7 * 0.8) }
if v > 0.95 { c = Color(hex: 0x0E0E12) }
image[x, y] = c
}
}
return image
}
}